Compare commits

...

814 commits

Author SHA1 Message Date
Craig Raw
3a5fa69fb6 fix occasional issue with cell reuse when avoiding updating cells during table size estimation 2025-11-05 12:20:02 +02:00
Craig Raw
4774830ce4 add yu12 to supported pixel formats on linux 2025-11-05 07:51:26 +02:00
Craig Raw
2f62a9e9c8 show signing keystores in transaction blockchain form for spends from multisig wallets 2025-11-04 10:45:31 +02:00
doblon8
75bcfe2253
update jzbar dependency to 0.2.1 2025-10-24 09:38:11 +02:00
Craig Raw
bedf1399ea request display of path when retreiving xpubs on ledger devices for any non-standard path 2025-10-24 08:38:48 +02:00
Craig Raw
58575793ea update openpnp-capture to 0.0.30-1 2025-10-21 15:55:32 +02:00
Craig Raw
6c9b580d4f refactor to use transaction parameters record object when creating a wallet transaction 2025-10-21 12:06:00 +02:00
doblon8
31909b7a15
use language-independent sid for windows users group permission 2025-10-21 09:35:18 +02:00
Craig Raw
092267339a adapt to use declarative style to for consolidation payments 2025-10-17 10:27:20 +02:00
Craig Raw
0974918cff hide confirmations in tooltip when showing inputs and outputs on the transactions table 2025-10-16 08:42:55 +02:00
Craig Raw
0f4c36b3c2 add ctrl+shift+left/right keyboard shortcuts for moving tabs left and right 2025-10-13 14:26:37 +02:00
Craig Raw
e1fe35fb74 update nsmenufx to avoid npe 2025-10-06 14:52:24 +02:00
Craig Raw
d37fd00c4b avoid using deprecated camera device type constants on recent macos versions 2025-10-06 13:27:12 +02:00
Craig Raw
5f54f86df7 bump to v2.3.1 2025-10-03 11:53:27 +02:00
Craig Raw
e2fa3df08d restore pre gradle 9 archive task behaviour for file permissions 2025-10-03 10:09:57 +02:00
Craig Raw
6d6ede9abe bump to v2.3.0 2025-10-03 08:08:25 +02:00
Craig Raw
cca9ab1056 improve implementation of adding dns payment information from psbt 2025-10-02 14:58:02 +02:00
Craig Raw
9e33861110 revert to javafx 23 due to jpackage launcher link bug 2025-10-01 12:10:39 +02:00
Craig Raw
c3d3fd1fda revert to java 22 and javafx 24 due to bug in jpackage launcher linking (jdk-8345810) 2025-10-01 11:52:45 +02:00
Craig Raw
ca8553ecb8 revert continuity camera device change as unsupported on macos 13 2025-09-30 12:33:33 +02:00
Craig Raw
d23ee8c086 upgrade openpnp-capture to iterate over continuity camera devices on mac 2025-09-30 12:24:22 +02:00
Craig Raw
e776a17ad4 upgrade jdbi to remove older caffeine dependency 2025-09-30 09:37:45 +02:00
Craig Raw
480ce1e476 fix deprecation warning 2025-09-29 17:39:48 +02:00
Craig Raw
656cd90b08 upgrade guava and commons-lang3 2025-09-29 14:34:07 +02:00
Craig Raw
8df0777959 upgrade to java 25 and javafx 25 2025-09-29 13:32:56 +02:00
Craig Raw
84566b92e6 remove unnecessary zbar native libraries 2025-09-29 12:39:49 +02:00
Craig Raw
7802510e58 support dns hrns in send to many dialog 2025-09-29 11:53:52 +02:00
Craig Raw
efb1eb1051 add initial sending to silent payments support 2025-09-29 08:37:07 +02:00
Craig Raw
6240667478 improve error dialog on payjoin receiver error 2025-09-02 09:49:39 +02:00
Craig Raw
2c27112dad update drongo 2025-08-16 13:03:03 +02:00
Craig Raw
6d53e1ed1d fix bluewallet spelling 2025-08-12 08:09:01 +02:00
Craig Raw
e8c5660897 allow transaction diagram input and output labels to expand into available width 2025-08-11 14:03:51 +02:00
Craig Raw
bef6c750bd upgrade to gradle 8.14.3 2025-08-07 11:28:27 +02:00
Craig Raw
4ec3603789 fix non bip32 child derivation test 2025-08-07 08:55:19 +02:00
Craig Raw
90c9f9733f display a warning if an output descriptor provided in the wallet settings will be modified for use 2025-08-05 09:28:52 +02:00
Craig Raw
64efcf67d3 display zero byte length witness elements as empty instead of op_0 2025-08-04 13:38:41 +02:00
Craig Raw
385d173948 handle npe connecting to bitcoin core with wallet functionality disabled 2025-08-01 07:44:34 +02:00
Craig Raw
d81b868049 add any dns payment instructions from loaded psbts if not already cached 2025-07-31 11:47:43 +02:00
Craig Raw
2ff7a15d1e add padding to writes when connected over tls 2025-07-31 10:34:56 +02:00
Craig Raw
f48fa7e23c support zero in pin keypad for onekey classic pin entry 2025-07-29 14:45:30 +02:00
Craig Raw
4632850e1e use improved dnssec validation and handle offline state when resolving bip 353 hrns 2025-07-29 12:52:10 +02:00
Craig Raw
5f62523710 support sending to and displaying bip353 human readable names and include dnssec proof in associated psbts 2025-07-24 14:36:11 +02:00
Craig Raw
9dcf210762 support creating transactions with the minimum relay fee rate configured by the user or set by the connected server 2025-07-17 09:15:59 +02:00
Craig Raw
c7e9a0a161 restore coingecko historical rate support by limiting to the last 365 days 2025-07-15 09:06:52 +02:00
Craig Raw
fa10714844 followup 2025-07-10 08:40:31 +02:00
Craig Raw
80105aee62 save webcam device unique id instead of name to config 2025-07-10 08:15:00 +02:00
Craig Raw
3c5fa58a16 suppress warnings for jzbar ffm usage 2025-07-09 12:46:57 +02:00
doblon8
2a2be2617c
replace jni-based zbar wrapper with ffm-based jzbar
* Replace ZBar JNI library implementation with jzbar

* Move ZBar.java to com.sparrowwallet.sparrow.control package

* Move ZBar.java to the com.sparrowwallet.sparrow.io package

* Switch to jzbar from Maven Central and update module/package imports accordingly

* Remove jzbar entry from extraJavaModuleInfo to avoid module patching error
2025-07-09 11:55:51 +02:00
Craig Raw
6c9a0d14cd compare on device unique id when choosing selected camera 2025-07-09 10:58:58 +02:00
Craig Raw
f82fcb58bb fix issue of including parent path elements in deterministic key when deriving child xpub from an output descriptor containing more than two child path elements 2025-07-09 10:26:49 +02:00
Craig Raw
5ec3bff6a4 fix jade configuration for signet and regtest networks 2025-07-02 16:45:10 +02:00
Oleg Koretsky
134dc826ba
do not change coin label unit on right click 2025-07-02 16:32:10 +02:00
Craig Raw
cd2a6623a4 fix restart menu options on linux standalone package 2025-07-01 16:07:45 +02:00
Craig Raw
49ab9e40e3 select first matching webcam by name 2025-06-28 16:10:57 +02:00
Craig Raw
cec7eac9ac fix selection of nearest supported resolution where chosen resolution is not available 2025-06-24 11:26:13 +02:00
Craig Raw
33e043fd9a include child derivations in output descriptor for bip 129 wallet export 2025-06-24 10:52:52 +02:00
Craig Raw
3aae26b196 bump to v2.2.4 2025-06-10 09:01:37 +02:00
Craig Raw
73d4fd5049 prevent double free when closing capture library 2025-06-09 14:43:06 +02:00
Craig Raw
a94380e882 minor specter diy ui tweaks 2025-06-07 11:23:06 +02:00
Craig Raw
e4dd4950bf prevent selection of unsupported bip322 format when signing a message with a connected device 2025-06-06 13:07:36 +02:00
Craig Raw
26ce1b3469 derive to maximum bip32 account level where child path in output descriptor contains more than two elements 2025-06-06 11:45:46 +02:00
Craig Raw
ebce34f3d1 minor tweaks 2025-06-05 14:23:02 +02:00
Craig Raw
f28e00b97e suggest opening the send to many dialog when adding multiple payments on the send tab 2025-06-05 10:31:37 +02:00
Craig Raw
25770c2426 suggest connecting to broadcast a finalized transaction if offlineand a server is configured 2025-06-05 09:40:17 +02:00
Craig Raw
799cac7b1f handle bitkey descriptor export format 2025-06-05 08:28:21 +02:00
Craig Raw
c265fd1969 fix cormorant server.version rpc issue 2025-06-04 17:18:31 +02:00
Craig Raw
890f0476b1 introduce delay before closing capture library 2025-06-04 15:23:48 +02:00
Craig Raw
4d93381124 improve electrum server script hash unsubscribe support 2025-06-04 14:52:33 +02:00
Craig Raw
364909cfa3 support nv12 capture pixel format on linux 2025-06-03 12:48:01 +02:00
Craig Raw
38f0068411 detect if electrum server supports scripthash unsubscribe capability 2025-06-03 12:38:03 +02:00
Craig Raw
8885e48ed9 request rgb3 pixel format on linux where returned format is unsupported 2025-06-02 16:28:44 +02:00
Craig Raw
31ce3ce68a further electrum server optimisations 2025-06-02 15:56:46 +02:00
Craig Raw
b0d0514617 remove possibility of task queueing in webcam service 2025-06-02 11:36:06 +02:00
Craig Raw
d7d23f9b58 always use the master wallet payment code when creating the notification transaction payload on the send tab 2025-06-02 09:41:46 +02:00
Craig Raw
3fdf093a26 use semaphore to ensure last webcam service task has completed before closing stream 2025-05-29 14:17:58 +02:00
Craig Raw
74c298fd93 iterate and remove faulty capture devices on opening qr scan dialog 2025-05-29 13:58:46 +02:00
Craig Raw
4298bfb053 bump to v2.2.3 2025-05-22 14:58:09 +02:00
Craig Raw
231eb13cee retrieve and show next block median fee rate in recent blocks view where available 2025-05-22 13:35:59 +02:00
Craig Raw
52470ee6d8 further electrum server optimization tweaks 2025-05-22 11:59:25 +02:00
Craig Raw
853949675e fix npe configuring recent blocks view on new installs 2025-05-22 08:44:39 +02:00
Craig Raw
098afebbe0 increase recent blocks estimated fee rate update frequency 2025-05-21 15:38:06 +02:00
Craig Raw
63c0a6d6e2 bump to v2.2.2 2025-05-21 13:29:16 +02:00
Craig Raw
77c305f90b tweak fix on recent blocks view 2025-05-21 10:29:01 +02:00
Craig Raw
276f8b4148 fix npe on null fee returned from server 2025-05-21 10:12:38 +02:00
Craig Raw
b3c92617c9 minor fixes on recent blocks view 2025-05-21 10:05:58 +02:00
Craig Raw
58635801fc add icons for external sources in settings and recent blocks view 2025-05-21 09:55:22 +02:00
Craig Raw
8c32bb3903 followup 2025-05-20 19:45:43 +02:00
Craig Raw
55a2c86a83 upgrade tor resource to fix uuid issue on recent macos platforms 2025-05-20 19:40:16 +02:00
Craig Raw
345e018eb9 repackage .deb installs to use older gzip instead of zstd compression 2025-05-20 13:41:12 +02:00
Craig Raw
45d2dee764 remove display of median fee rate where fee rates source is set to server 2025-05-20 12:04:06 +02:00
Craig Raw
250bc84060 bump to v2.2.1 2025-05-20 10:58:21 +02:00
Craig Raw
c3dba8ede6 bump to v2.2.0 2025-05-19 11:37:52 +02:00
Craig Raw
db478f8da6 further followup tweaks 2025-05-19 09:11:12 +02:00
Craig Raw
4ab9a9f681 followup tweaks 2025-05-16 18:49:58 +02:00
Craig Raw
c078aea3b4 show total in transaction diagram when constructing multiple payment transactions 2025-05-16 17:00:40 +02:00
Craig Raw
af4a283b3f increase trezor device libusb timeout 2025-05-16 10:02:03 +02:00
Craig Raw
892885c0b1 make wallet summary table grow horizontally with dialog sizing 2025-05-15 15:01:09 +02:00
Craig Raw
d4a1441d65 more recent blocks tweaks 2025-05-15 12:42:53 +02:00
Craig Raw
1605cd2619 followup 2025-05-15 12:10:54 +02:00
Craig Raw
b4d34aacc5 tweak block cube median fee font styling 2025-05-15 12:03:24 +02:00
Craig Raw
1a4f0113c7 followup 2 2025-05-15 10:49:09 +02:00
Craig Raw
055e3ac496 followup 2025-05-15 10:00:03 +02:00
Craig Raw
d0da85171c rename sparrow package to sparrowwallet and sparrowserver on linux 2025-05-15 09:28:42 +02:00
Craig Raw
af4c68a09c update tor resource library and switch to resource-filterjar plugin 2025-05-14 11:38:17 +02:00
Craig Raw
b1ab157ee3 cormorant: add block stats rpc call, and prefer for block summaries 2025-05-14 10:52:21 +02:00
Craig Raw
94b27ba7e8 add recent blocks view 2025-05-14 08:19:21 +02:00
Craig Raw
e697313259 add accessible text to improve screen reader navigation 2025-05-08 10:10:50 +02:00
Craig Raw
1b0e5e9726 revert rpm package name change 2025-05-07 16:05:17 +02:00
Craig Raw
df0c4310ca optimize and reduce electrum server rpc calls #3 2025-05-07 16:03:02 +02:00
Craig Raw
474f3a4e91 add custom filterjar plugin to filter out unneeded native binaries per platform 2025-05-07 10:37:56 +02:00
Craig Raw
c6e42d8fe2 rename rpm package name from sparrow to sparrowwallet to avoid conflicts 2025-05-05 15:11:04 +02:00
Craig Raw
3698ca8e85 reduce tooltip show delay to 200ms 2025-05-05 14:53:24 +02:00
Craig Raw
53c5a8d2df update kmp-tor to 2.2.1 and remove runtime module config 2025-05-05 14:51:15 +02:00
Craig Raw
3d85491e6b add block summary service 2025-05-05 14:43:42 +02:00
Craig Raw
c77f52f7f6 optimize and reduce electrum server rpc calls #2 2025-04-29 12:49:58 +02:00
Craig Raw
e3138f3392 optimize and reduce electrum server rpc calls 2025-04-28 14:39:30 +02:00
Craig Raw
7a4015fdb5 convert images to theme aware svg for all wallet models and dialogs 2025-04-25 17:37:32 +02:00
Craig Raw
94d15c09e6 cormorant: avoid calling listwalletdir rpc on initialization due to extremely slow response on windows 2025-04-18 09:43:57 +02:00
Craig Raw
71ac72e9f6 upgrade internal tor to 0.4.8.15 2025-04-17 14:17:26 +02:00
Craig Raw
be8b56e355 fix inclusion of fees on wallet label exports 2025-04-14 16:27:12 +02:00
Craig Raw
af8505c0eb support send and display of pay to anchor outputs 2025-04-14 15:50:10 +02:00
Craig Raw
5edabf2e14 minor fixes to private key sweep on bitcoin core 2025-04-11 13:43:19 +02:00
Craig Raw
c73ebdc8a2 show address where available on input and output tooltips in transaction tab tree 2025-04-10 17:01:18 +02:00
Craig Raw
c9d7b8ef9a dynamically truncate input and output labels in the tree on a transaction tab, and add tooltips if necessary 2025-04-10 15:42:04 +02:00
Craig Raw
b3a6340c45 simplify camera pixel format prioritisation 2025-04-08 14:50:38 +02:00
Craig Raw
0975d12155 sort camera pixel formats on linux only 2025-04-08 13:48:59 +02:00
Craig Raw
e31aa7fc80 avoid server address resolution for public servers, and assume non local for failures where a proxy is configured 2025-04-06 20:55:35 +02:00
Craig Raw
b777c8c64d fix for building on headless with earlier javafx 2025-04-03 16:11:23 +02:00
Craig Raw
4176f76ffc update to build on ubuntu 22.04 2025-04-03 15:59:07 +02:00
Craig Raw
64dac72f4f show transaction diagram fee percentage as less than 0.01% rather than 0.00% 2025-04-03 15:35:51 +02:00
Craig Raw
e29559f59c fix issue parsing remote coldcard xpub encoded on a different network 2025-04-03 15:18:42 +02:00
Craig Raw
b1223ef064 reset preferred table column widths on adjustment 2025-04-03 14:41:43 +02:00
Craig Raw
6f0a30cc25 prefer yuyv to mjpg capture format 2025-04-03 14:15:19 +02:00
Craig Raw
2fa8e5fd70 improve tabs and transaction diagram tooltips with long labels 2025-04-03 14:10:02 +02:00
Craig Raw
a8f7ce9e34 add tooltip for truncated labels in table cells 2025-04-02 10:32:11 +02:00
Craig Raw
c946ef7479 upgrade bouncy castle, pgpainless and logback 2025-04-01 14:59:55 +02:00
Craig Raw
7fa13901d4 fix typo 2025-03-20 12:07:07 +02:00
Craig Raw
8a88488a42 update openpnp-capture to v0.0.28-5, fix typo 2025-03-20 11:48:22 +02:00
Craig Raw
25a3f5539d sort retrieved capture formats in order of supported, unknown and unsupported pixel formats 2025-03-20 10:47:07 +02:00
Craig Raw
520c5f2cfa revert initialization change, configure openpnp debug logging 2025-03-14 11:27:25 +02:00
Craig Raw
d8877a259c initialize capture library in service thread, fix sigsegv fault 2025-03-14 09:40:30 +02:00
Craig Raw
7de63b2b5f suppress unneeded warning on zoom detection 2025-03-13 17:55:49 +02:00
Craig Raw
f1c4b8aa69 support camera zoom during capture with mouse scroll 2025-03-13 17:43:55 +02:00
Craig Raw
6f6d61fb75 minor webcam cross platform fixes 2025-03-13 16:54:47 +02:00
Craig Raw
2c4de99fad improve capture display efficiency, fix resizing bug and refactor 2025-03-13 13:52:01 +02:00
Craig Raw
3e197eb310 support capturing using additional webcam resolutions of fhd and uhd4k 2025-03-13 08:30:53 +02:00
Craig Raw
bd5af560ff fix non-zero account script type detection when signing a message on trezor devices 2025-03-12 08:52:08 +02:00
Craig Raw
3b9551a8c6 replace sarxos/openimaj library with openpnp-capture library 2025-03-11 16:21:27 +02:00
Craig Raw
289a4453a4 fix issue with random ordering of keystore origins on labels import 2025-03-10 11:38:26 +02:00
Craig Raw
27e21c890f refactor ioutils to drongo 2025-03-04 15:08:26 +02:00
Craig Raw
4239a56bc1 show warning when importing a wallet with a derivation path matching another script type 2025-03-04 11:48:03 +02:00
Craig Raw
5c9de07d48 prefer verifying dropped file over default file where file is not in manifest 2025-03-03 14:17:53 +02:00
Craig Raw
9a8a25344a bump to v2.1.4 2025-02-27 12:51:30 +02:00
Craig Raw
be86b4feaa fix access issue with macos show/hide windowing commands 2025-02-27 11:08:01 +02:00
Craig Raw
37763e9557 verify dropped release file instead of first platform specific release file found 2025-02-27 11:03:53 +02:00
Craig Raw
80c4f4f5f6 make wallet labels export and import scannable 2025-02-26 12:01:05 +02:00
Craig Raw
6c3fe93d1e exclude heights of confirming txes from wallet labels export 2025-02-26 11:44:27 +02:00
Craig Raw
76eff2de48 merge wallet labels optional fields draft implementation 2025-02-26 10:47:17 +02:00
Craig Raw
07a6818823 use default key origin information when importing a descriptor without key origin info 2025-02-25 10:55:37 +02:00
Craig Raw
2253a1bb97 add support for onekey pro and classic 1s hardware wallets 2025-02-20 17:04:56 +02:00
Craig Raw
36ee8add08 add bip47 notification transaction test 2025-02-19 11:31:32 +02:00
Craig Raw
883e75c0df add copy payment code to transaction diagram outputs context menu 2025-02-19 08:45:52 +02:00
Craig Raw
cc908b09c7 upgrade to libusb 1.0.27 on all platforms 2025-02-18 15:30:34 +02:00
Craig Raw
ce963ed5b6 add specific handling for invalid windows device drivers on trezor devices 2025-02-18 13:32:02 +02:00
Craig Raw
951e33dc06 fix handling of high account numbers on ledger devices 2025-02-18 12:45:50 +02:00
Craig Raw
6a6a6b1cca additionally check for trezor model against internal name, improve exception handling on no match 2025-02-16 08:43:45 +02:00
Craig Raw
8953d404fa fix stripping leading zeros from master fingerprint on importing some trezor models 2025-02-14 18:42:44 +02:00
Thauan Amorim
b366177782
add show transaction as qr button to signed transaction tab when offline
* [feature/1630] Add QR code button on signed transaction screen

* [feature/1630] Button positioning improvements

* [feature/1630] Added owner to qrDisplayDialog
2025-02-13 09:05:11 +02:00
Craig Raw
d0c827c2c7 fix various minor issues around multisig keystore labelling and export button visibility 2025-02-13 08:43:55 +02:00
Craig Raw
5c29bf51b7 handle scanning and pasting server urls in the electrum format 2025-02-11 14:03:43 +02:00
Craig Raw
d426703dcc fix account discovery on bitbox02 2025-02-11 13:18:21 +02:00
Craig Raw
78f0721168 bump to v2.1.3 2025-02-08 16:12:38 +02:00
Craig Raw
20d3f07059 draft implementation of optional bip329 fields 2025-02-08 11:43:59 +02:00
Craig Raw
1140a678ad followup 2025-02-08 11:38:41 +02:00
Craig Raw
6e8d44bc8c set script type import button as default after bip39 wallet discovery returns empty 2025-02-08 09:44:21 +02:00
Craig Raw
ad3b384feb fix loading hex txn files with trailing whitespace 2025-02-08 09:30:21 +02:00
Craig Raw
f38350b38d prefill derivation to default path for script type on watch only keystores 2025-02-07 16:22:20 +02:00
Craig Raw
62060c9839 fix unsigned byte to int conversion for ledger get_more_elements command 2025-02-07 15:56:35 +02:00
Craig Raw
8975f6f666 followup 2025-02-07 14:57:21 +02:00
Craig Raw
c7351cd191 indicate historical rates support in exchange source drop-down 2025-02-07 14:52:17 +02:00
Craig Raw
62b1dc3900 support coldcard p2tr address display and show correct address for script type on message sign 2025-02-06 16:12:49 +02:00
Craig Raw
f37ff47850 fix invalid claimed length error on transaction file load 2025-02-06 15:36:37 +02:00
Craig Raw
cfaa1f6c6e add local network usage description for macos 15 2025-02-06 10:08:28 +02:00
Craig Raw
91c94b94eb upgrade flyway to v9.22.3 2025-02-06 09:37:51 +02:00
Craig Raw
a5eb7da067 bump to v2.1.2 2025-02-05 09:21:21 +02:00
Craig Raw
195dbcef3b ensure /sys devices are writeable before calling udevadm 2025-02-04 20:53:50 +02:00
Craig Raw
24955604e2 use standard font in label cells on macos 2025-02-04 20:16:28 +02:00
Craig Raw
0305afbc02 catch and log any linkage errors while enumerating hwws 2025-02-04 19:56:07 +02:00
Craig Raw
d4c3c3afa8 bump to v2.1.1 2025-02-04 13:58:47 +02:00
Craig Raw
cda7835672 revert rpm spec file to use %post 2025-02-04 11:45:53 +02:00
Craig Raw
b4b679dd16 fix rpm spec path reference 2025-02-04 11:22:06 +02:00
Craig Raw
3efaec2859 verbose rpm build 2025-02-04 11:14:06 +02:00
Craig Raw
a53812c12f improve rpm spec and deb postinst scripts 2025-02-04 11:07:30 +02:00
Craig Raw
686c008e97 allow server urls to be pasted into the server settings host fields 2025-02-03 18:02:18 +02:00
Craig Raw
4d60a20336 add mempool.space exchange rate source 2025-02-03 14:47:23 +02:00
Craig Raw
9879889875 bump to v2.1.0 2025-02-03 08:56:11 +02:00
Craig Raw
4aee89a35b fix trezor one passphrase protection deactivation 2025-01-31 15:01:32 +02:00
Craig Raw
fd9648efd1 fix trezor one passphrase behaviour, add bitbox02 non-standard path check 2025-01-31 13:50:54 +02:00
Craig Raw
8f438cd0bc rename preferences to settings 2025-01-31 11:41:20 +02:00
Craig Raw
8b47701dbe check for and delete hwi directory on macos and windows 2025-01-31 11:04:40 +02:00
Craig Raw
ff571c3df4 ensure consistent key ordering in ledger multisig wallet policy 2025-01-30 16:19:49 +02:00
Craig Raw
201c9ccca3 truncate labels to persistable max label length and notify user via tooltip 2025-01-30 14:50:46 +02:00
Craig Raw
cbae280cc8 add jade plus to udev rules 2025-01-30 12:05:08 +02:00
Craig Raw
1f44229e62 fix rpm spec file 2025-01-29 14:34:05 +02:00
Craig Raw
541c71a002 verbose build 2025-01-29 13:10:29 +02:00
Craig Raw
bb08b5599c install rpmbuild on ubuntu arm64 2025-01-29 12:50:32 +02:00
Craig Raw
fd70f03259 add arm64 runners 2025-01-29 12:36:43 +02:00
Craig Raw
fbca6c691d add blackie.c3-soft.com testnet4 public electrum server 2025-01-28 13:24:10 +02:00
Craig Raw
e8cd56388f upgrade to javafx 23.0.2 2025-01-28 13:20:31 +02:00
Craig Raw
3dfd8210a8 store treetable column sort on adjustment, and restore on wallet load 2025-01-28 12:53:10 +02:00
Craig Raw
f9199b65f0 store treetable column widths on adjustment, and restore on wallet load 2025-01-28 10:33:46 +02:00
Craig Raw
6a001bd67f exclude taproot wallets and jade, tapsigner and satochip hwws from requiring non witness tx in psbts 2025-01-23 15:40:44 +02:00
Craig Raw
ee2f387cd5 retrieve, store and use device registrations to avoid unncessary reregistration on ledger multisig wallets 2025-01-22 16:26:42 +02:00
Craig Raw
95200c7143 improve bitbox pairing flow 2025-01-22 12:59:23 +02:00
Craig Raw
d7511c62bf match new behaviour in bitcoin core 28 for default windows data dir 2025-01-22 09:49:50 +02:00
Craig Raw
7a5f4ff294 reduce default tooltip show delay to 400ms 2025-01-22 07:51:04 +02:00
Craig Raw
2b145cb9cc update install udev rules dialog 2025-01-21 13:55:45 +02:00
Craig Raw
13bd05853c add udev rules installation 2025-01-21 13:11:19 +02:00
Craig Raw
59f3338842 fix coldcard last error check 2 2025-01-21 11:34:10 +02:00
Craig Raw
2cc38dc8b0 fix coldcard last error check 2025-01-21 10:41:42 +02:00
Craig Raw
0e9d97c221 replace hwi with lark 2025-01-21 10:00:38 +02:00
Craig Raw
fb0fd013d9 add lark as submodule 2025-01-20 09:49:01 +02:00
Craig Raw
e7510d2275 rename max block size constant for clarity 2025-01-20 09:17:05 +02:00
Craig Raw
e92d0f9b58 show input label in input tooltip on transaction diagram if present 2025-01-16 14:21:42 +02:00
Craig Raw
ea23bb51d9 upgrade lanterna and remove java 22 workaround 2025-01-16 12:51:10 +02:00
Craig Raw
2d3bf0b2fe skip labelled addresses when retrieving an unused address from the receive tab and send tab pay to wallet 2025-01-16 12:18:12 +02:00
Craig Raw
617ad380c0 improve socket address resolution handling 2025-01-15 15:22:15 +02:00
Craig Raw
29ac15846d disable broadcast progress bar if disconnected, and re-enable if connected again 2025-01-15 13:07:18 +02:00
Craig Raw
f4acd3e587 add option to bitcoin core and private electrum server selection to scan url from a qr code 2025-01-15 11:59:45 +02:00
Craig Raw
f057b92729 allow camera image mirroring to be changed from image context menu and application view menu 2025-01-15 11:07:45 +02:00
Craig Raw
4bf02f833c remove payjoin verification step to check there is no previous utxo information in the psbt as per bip78 change 2025-01-15 09:26:48 +02:00
craigraw
7ef51e6a5d
Merge pull request #1591 from Toporin/patch-satochip-multisig
default to first keystore for signing path if satochip keystore cannot be determined
2025-01-15 09:14:06 +02:00
Craig Raw
fdbcea1625 enable electrum rpc batching on mempool-electrs servers 2025-01-15 09:09:25 +02:00
Craig Raw
218c2720e0 always select a new address when sending multiple payments to the same open wallet 2025-01-15 08:21:48 +02:00
Toporin
91ad82a21c Patch https://github.com/Toporin/SatochipApplet/issues/15
First try to recover derivation path from satochip keystore, otherwise from first keystore as default value.
2025-01-14 15:23:38 +01:00
Toporin
f4b3b3d55a Merge branch 'master' into patch-satochip-multisig 2025-01-14 15:01:35 +01:00
Craig Raw
db1b55cfa0 cormorant: report configuration error when both core data folder and user/pass is not specified 2025-01-14 15:12:39 +02:00
Craig Raw
bd0aca66b5 cormorant: skip waiting for ibd to complete when networkactive is false 2025-01-14 14:15:44 +02:00
Toporin
22ad1cc5d1 Patch https://github.com/Toporin/SatochipApplet/issues/15
Null exception can be thrown when signing a multisig transaction
from a Sparrow wallet reconstructed from a Bitcoin descriptor.
This happens when the user did not configure any keystore
with the corresponding Satochip card ('import' button).
In this case, the 'fullpath' derivation path remains undefined,
leading to the exception.
2025-01-14 13:00:52 +01:00
Craig Raw
d07a5f0a01 cormorant: add fee to mempool tx entries returned from get history 2025-01-14 12:23:18 +02:00
Craig Raw
947013e088 only show cpfp rate if child fee increases effective fee rate 2025-01-14 10:44:53 +02:00
Craig Raw
25f441a6a8 update javafx to 23.0.1 2025-01-13 10:22:54 +02:00
Craig Raw
ee5015f0d5 update macos runner 2024-12-17 12:17:00 +02:00
Craig Raw
4f00fabd23 upgrade tern to 1.0.6 2024-11-28 11:03:49 +02:00
Craig Raw
6927423d68 switch from controlsfx platform to drongo ostype 2024-11-26 11:30:32 +02:00
Craig Raw
fffa636956 followup 2024-11-26 11:00:26 +02:00
Craig Raw
a02ac3dcd2 use versionless extra module info definitions where possible 2024-11-26 10:49:38 +02:00
Craig Raw
e56e3d9394 switch from custom to gradlex extra-java-module-info plugin, cleanup module definitions 2024-11-26 09:31:34 +02:00
Craig Raw
119d00233d fix cast to http proxy supplier 2024-11-25 16:34:17 +02:00
Craig Raw
da427610d6 move version class to drongo 2024-11-25 15:53:27 +02:00
Craig Raw
46034b8f11 repackage http client as tern library dependency 2024-11-25 13:17:39 +02:00
Craig Raw
d49d5967b2 improve exception handling when loading paynym avatars 2024-11-25 10:30:28 +02:00
Craig Raw
484ef5f399 upgrade jcommander to 2.0 2024-11-20 13:09:28 +02:00
Craig Raw
740c00d1ba add output descriptor accessors and copy function 2024-11-19 10:46:48 +02:00
Craig Raw
dfae39255e add equals and hashcode to output descriptor 2024-11-18 15:14:40 +02:00
Craig Raw
c2bce893db fix psbtv2 output amount serialization 2024-11-18 13:05:41 +02:00
Craig Raw
ef063fde75 reverse prevtxid byte ordering during serialization and deserialization 2024-11-18 12:44:14 +02:00
craigraw
adb446de3e
Merge pull request #1537 from ottosch/hotkey-close-dialog
close wallet name dialog with escape key
2024-11-18 08:22:40 +01:00
ottosch
d040f186a2 Close wallet name dialog with ESC 2024-11-15 18:28:52 -03:00
Craig Raw
b4f9c52413 bump required java version to 22 2024-11-15 23:06:48 +02:00
Craig Raw
7527dd0909 allow hardened character selection when writing key 2024-11-15 16:33:10 +02:00
Craig Raw
b0be8ca7c2 add psbt v2 support 2024-11-15 12:34:46 +02:00
Craig Raw
1e0c0c1c75 replace forward slash with underscore in file names when saving psbts 2024-11-12 08:48:50 +02:00
Craig Raw
d731f7296b improve jade qr keystore import descriptions 2024-11-12 08:26:07 +02:00
Craig Raw
12034a07d7 add specter diy multisig option to wallet import menu 2024-11-05 08:49:36 +02:00
Craig Raw
60e3d4e107 be more lenient in parsing pasted btc values to send tab textfields 2024-11-04 08:03:21 +02:00
Craig Raw
ad8e17a3a0 add eckey arithmetic functions 2024-10-31 17:02:42 +02:00
Craig Raw
3e676eadcb add support for x25519 and secp256r1 keys 2024-10-30 13:04:35 +02:00
Craig Raw
3640db3d3d simplify required maven build repositories 2024-10-28 13:27:44 +02:00
Craig Raw
d0bf55de70 fix regression to display tabular numbers in a monospace font 2024-10-28 10:04:33 +02:00
Craig Raw
ad0b6adfd8 upgrade hummingbird to v1.7.4 2024-10-28 09:48:11 +02:00
Craig Raw
92b32b0d99 drongo: fix build instructions 2024-10-21 09:28:52 +02:00
Craig Raw
233addc1b7 update fxsvgimage to v1.1 2024-10-10 09:07:12 +02:00
Craig Raw
1d8c37066e update flyway to v9.1.3 2024-10-10 09:04:01 +02:00
Craig Raw
c450efe499 improve keystore import panel spacing in linux 2024-10-08 10:32:36 +02:00
craigraw
34bcc87468
Merge pull request #1512 from dcavacec/fix-issue-1510
improve handling of spacing and links in accordion panels
2024-10-08 10:24:59 +02:00
David Cavaceci
2aac365039 PR #1510 Feedback: set min height, use AppServices url handling 2024-10-07 09:59:58 -05:00
Craig Raw
7e68ecffd3 retrieve fee rates from configured source on non-mainnet networks where available 2024-10-07 12:13:24 +02:00
David Cavaceci
bf457620db Fix #1510: Handle spacing and links in content box messages. 2024-10-02 11:30:06 -05:00
Craig Raw
e50fe4c68c switch from paynym.is to paynym.rs and tor equivalents, update child wallet labels on displaying paynym dialog 2024-09-30 11:31:55 +02:00
Craig Raw
1bbc586cd6 set transaction tab label to transaction label if available 2024-09-24 08:49:04 +02:00
Craig Raw
e1dab3a48e update compress and jackson libs 2024-09-20 11:00:28 +02:00
Craig Raw
73b672a7ef fix arm64 architecture on server deb control file 2024-09-20 10:20:07 +02:00
Craig Raw
b142c54c68 update readme for java 22 2024-09-18 15:09:04 +02:00
Craig Raw
58d09c3ba7 bump to v2.0.1 2024-09-18 14:57:23 +02:00
Craig Raw
d5a7a5b855 update reproducible build instructions for java 22 2024-09-18 14:56:06 +02:00
Craig Raw
fcb83f8bfa bump to v2.0.0 2024-09-18 13:36:46 +02:00
Craig Raw
f187603ec3 upgrade to hwi 3.1.0 2024-09-18 09:23:30 +02:00
Craig Raw
8d7308bc37 add warning when sighash none is selected 2024-09-16 08:27:29 +02:00
Craig Raw
e44d1393f5 delegate to wallet model usb support 2024-09-13 13:13:49 +02:00
Craig Raw
33ba472843 set minimum fee rate to the lower of estimated and user configured fee rates 2024-09-13 13:04:04 +02:00
Craig Raw
faa81f2273 replace message after comparison check with that provided in signed file 2024-09-13 09:49:24 +02:00
Craig Raw
0646c8aa28 show warning dialog on broadcast if a transaction has a fee rate beyond the range slider maximum 2024-09-13 09:30:58 +02:00
Craig Raw
deb47ca002 truncate loading log and avoid automatic scrolling to the right 2024-09-12 14:30:05 +02:00
Craig Raw
ec131bb8da delay opening new dialogs on startup in wayland 2024-09-11 12:03:13 +02:00
Craig Raw
31f287125f delay show password dialog until initial app window open has completed 2024-09-06 13:04:22 +02:00
Craig Raw
e131f645f6 followup 2024-09-05 12:01:12 +02:00
Craig Raw
eabc0da6d5 specify deb control file when building headless to restrict dependencies 2024-09-04 15:11:51 +02:00
Craig Raw
49573d1075 upgrade to javafx 22 with a minimum requirement of macos 11 and gtk3 2024-09-04 12:04:00 +02:00
Craig Raw
17093dbf72 add menu items to the message sign dialog to save a text file for signing, and load a signed message file 2024-09-03 12:03:53 +02:00
Craig Raw
c2b5b24702 add passport multisig to wallet import menu 2024-09-02 12:40:54 +02:00
Craig Raw
65f1fa7cf8 remove oxt.me as fee rates source 2024-08-26 11:34:31 +02:00
Craig Raw
cbee341544 use monospace font for addresses in utxo table 2024-08-22 12:01:57 +02:00
Craig Raw
95b1aa8e48 rewrite derivation paths on file and card imports, compare multisig keystore derivations with rewritten paths 2024-08-22 11:07:29 +02:00
Craig Raw
af89be96e5 show warning if data is too large for display as static qr 2024-08-21 09:09:08 +02:00
Craig Raw
fad960c192 terminal: restore pre java 22 behaviour for system.console call 2024-08-20 15:18:44 +02:00
Craig Raw
1adeef04db preserve file timestamps on macos build zip 2024-08-20 13:14:44 +02:00
Craig Raw
47f925b677 use uri instead of deprecated url constructor 2024-08-09 10:24:44 +02:00
Craig Raw
5db3096386 upgrade java to 22.0.2 2024-08-09 09:45:23 +02:00
Craig Raw
62e98b0090 change windows installer from exe to msi 2024-08-09 09:44:33 +02:00
Craig Raw
76490604ac upgrade to gradle 8.9 2024-08-08 13:30:39 +02:00
Craig Raw
783733b9d3 followup 2024-08-07 14:56:29 +02:00
Craig Raw
041b5aa34c recover slip39 shares to keystore seed and store as single slip39 share 2024-08-07 14:45:09 +02:00
Craig Raw
33d23e9ea5 Merge branch 'master' of github.com:sparrowwallet/sparrow 2024-07-31 15:16:00 +02:00
Craig Raw
b3f6cc88f0 add trezor safe 5 support (hwi update still required) 2024-07-31 15:13:45 +02:00
craigraw
b912aa2eb9
Merge pull request #1437 from BenWestgate/1436-single-desktop-window
avoid adding a new window menu command on linux desktop managers
2024-07-22 13:41:24 +02:00
Craig Raw
d894343457 show a warning dialog before refreshing a passphrase wallet where all the history has changed 2024-07-17 12:22:55 +02:00
Craig Raw
fb1e1cefda upgrade zbar to v0.23.93 2024-07-16 13:16:49 +02:00
Craig Raw
d960bdce96 include multisig threshold and psbt keytype fixes 2024-07-11 11:49:17 +02:00
Craig Raw
fb679c0199 enable close button on multisig backup dialog 2024-06-29 10:11:24 +02:00
Ben Westgate
9ecfe0e94f
Add SingleMainWindow=true to Sparrow.desktop
This prevents desktop environments from displaying "New Window" as one of the right click actions in the side bar and application list.
2024-06-08 13:47:48 -05:00
Craig Raw
1bc2f7d69f add missing previous outputs to a loaded psbt if available from open wallets 2024-06-01 09:43:48 +02:00
Craig Raw
6e4d308c79 add optional bbqr selection for qr display on krux wallets 2024-05-29 09:26:14 +02:00
Craig Raw
afb6037449 show warning when sweeping a private key that contains insufficient funds for the given fee rate 2024-05-27 09:48:15 +02:00
Craig Raw
369983748d bump to v1.9.2 2024-05-14 11:36:19 +02:00
Craig Raw
0d16c87b40 minor caravan name update 2024-05-13 11:36:04 +02:00
Craig Raw
b59a65dcfe export electrum wallets with only usb capable hardware wallets as hardware keystore types 2024-05-10 09:54:33 +02:00
Craig Raw
87cc28e0a4 improve error message when importing invalid coldcard multisig config 2024-05-09 15:44:47 +02:00
Craig Raw
1187925543 fix wallet loading failure icon color in tab label when using dark theme 2024-05-09 14:42:13 +02:00
Craig Raw
cd4edab4ae add testnet4 public server and tx broadcast from mempool.space 2024-05-09 13:00:11 +02:00
Craig Raw
daf320f36b optionally show output descriptor qr export as bbqr, update coldcard import and export instructions 2024-05-09 12:20:54 +02:00
Craig Raw
f6ff92865b avoid adding testnet symlink in windows as admin privileges are required 2024-05-08 16:15:31 +02:00
Craig Raw
d420c71673 add testnet4 network support 2024-05-08 15:50:15 +02:00
Craig Raw
07101b3ca0 add additional fingerprint check when finding signing nodes from provided psbt input derivation paths 2024-05-06 09:51:34 +02:00
Craig Raw
00f7f3f5b3 update default derivation path for unknown unchained signer 2024-05-06 08:17:04 +02:00
Craig Raw
5d2c133401 fix single character multisig output descriptor threshold parsing issue 2024-05-03 12:16:25 +02:00
Craig Raw
7b0dfd66a7 fix premature decompression of bbqr zlib parts 2024-05-03 11:46:33 +02:00
Craig Raw
83719e7df2 fix signing regression on psbts with external inputs 2024-04-30 11:55:28 +02:00
Craig Raw
f1b246f0b0 bump to v1.9.1 2024-04-26 09:07:37 +02:00
Craig Raw
599880ea5c improve samourai backup import error message 2024-04-25 15:21:13 +02:00
Craig Raw
d625bab02e bump to v1.9.0 2024-04-25 15:13:10 +02:00
Craig Raw
1676676e06 remove whirlpool and soroban features and dependencies 2024-04-25 15:11:22 +02:00
Craig Raw
f7e603118f bump to v1.8.7 2024-04-24 11:27:58 +02:00
Craig Raw
f6fd889712 ignore scroll events with zero scroll movement 2024-04-24 09:00:36 +02:00
Craig Raw
21d91d3d10 add additional check against existing child wallet names when suggesting new accounts to add 2024-04-23 08:32:58 +02:00
Craig Raw
f1cddc28e7 copy context menu changes on receive tab 2024-04-22 16:57:44 +02:00
Craig Raw
1887e1c7b0 allow editing of the output descriptor of a new account on a watch only wallet 2024-04-22 13:29:41 +02:00
Craig Raw
3e870f362d fall back to sparrow logo for faulty wallet icon loads 2024-04-22 12:18:28 +02:00
Craig Raw
665d70b845 fix freeze on account settings tab loading wallet type icon 2024-04-22 12:05:24 +02:00
Craig Raw
c2cbe62a5a fix showing multiple password dialogs on bip47 paynym wallet linking 2024-04-22 10:02:02 +02:00
Craig Raw
c6b6e74515 maintain strong reference to key derivation service until action completion 2024-04-19 10:49:42 +02:00
Craig Raw
8ddc494b53 parse output descriptors with missing fingerprints in key origin information 2024-04-19 09:59:48 +02:00
Craig Raw
33f439f49a minor text changes 2024-04-19 09:43:18 +02:00
Craig Raw
d68ab40c94 add wallet import for samourai backup export 2024-04-18 16:04:06 +02:00
Craig Raw
31346e2afa add mix selected button to the postmix account in desktop and terminal 2024-04-18 13:22:50 +02:00
Craig Raw
c407a41475 add fine adjustment control for fee rate slider using mouse scroll 2024-04-18 12:12:40 +02:00
Craig Raw
8baa8e2e96 support changing the frame rate of animated qrs with mouse scroll on the qr image 2024-04-18 11:45:10 +02:00
Craig Raw
72adbe44a7 cleanup 2024-04-18 10:06:35 +02:00
Craig Raw
dd64c18c21 followup 2024-04-18 09:45:58 +02:00
Craig Raw
2354b061a9 add user write permissions to jvm legal files 2024-04-18 09:29:11 +02:00
Craig Raw
a5050117a3 handle null proxy configuration when fetching proxy 2024-04-17 14:11:41 +02:00
Craig Raw
f245b57022 add export search results as csv functionality 2024-04-17 14:01:55 +02:00
Craig Raw
d3752a856b make search wallet dialog non-modal, close any non-modal dialogs on application closing 2024-04-17 12:33:13 +02:00
Craig Raw
fe7dba6d83 support searching on multiple addresses, txids and utxos in a search phrase separated by spaces 2024-04-17 09:22:31 +02:00
Craig Raw
2d0a94d024 add copy context menu to date/address/output column in search wallet dialog 2024-04-16 14:34:27 +02:00
Craig Raw
41146310d6 allow label editing in the search wallet dialog 2024-04-16 13:26:58 +02:00
Craig Raw
a167f6aedb add show all wallets summary 2024-04-16 12:31:56 +02:00
Craig Raw
0fed7c45ee change transactions and utxos csv export to use utc timezone for dates 2024-04-16 10:35:42 +02:00
Craig Raw
5a0df265bc unminimize existing app window when second instance is launched 2024-04-15 12:40:17 +02:00
Craig Raw
646b8b0e65 show discover button when adding accounts on a bitcoin core connection, but warn user account discovery is not generally supported if no accounts are found 2024-04-15 09:55:24 +02:00
Craig Raw
c9b40b1973 fall back to coldcard singlesig import if multisig format import fails 2024-04-15 08:32:38 +02:00
Craig Raw
9ec5b6ce26 fix network enum error on startup in signet 2024-04-14 08:13:30 +02:00
Craig Raw
93893111c6 fix issue where gap limit was not increased when wallet could partially sign transaction 2024-04-12 09:12:33 +02:00
Craig Raw
3600d32ffd bump to v1.8.6 2024-04-11 13:48:12 +02:00
Craig Raw
1e0855c11d use hwi 3.0.0 built on ubuntu 20.04 amd64 2024-04-11 10:46:44 +02:00
Craig Raw
15cb028951 merge whirlpool 1.x client using decentralized soroban 2024-04-11 08:37:28 +02:00
Craig Raw
e178168bec fix npe on soroban counterparty timeout 2024-04-11 08:09:08 +02:00
Craig Raw
5696e00cb5 fix stonewallx2 transaction address selection 2024-04-10 12:42:11 +02:00
craigraw
594a873f20
Merge pull request #1369 from zeroleak/whirlpool-1.0.6
upgrade to whirlpool 1.0.6 to support simpler onion switching
2024-04-10 10:34:47 +02:00
zeroleak
da30d4223a upgrade whirlpool-client 1.0.6 2024-04-10 09:52:41 +02:00
Craig Raw
2441b4d7c3 handle coldcard singlesig file imports containing p2sh-p2wsh 2024-04-10 07:44:24 +02:00
Craig Raw
cc739a71e9 followup 2024-04-09 11:03:20 +02:00
Craig Raw
5f98eb9eb9 followup 2024-04-08 13:45:52 +02:00
Craig Raw
5aa25b98c3 supporting importing labels from electrum history csv using wallet labels import 2024-04-08 13:38:36 +02:00
Craig Raw
5058cd283d use hwi 3.0.0 built on ubuntu 20.04 aarch64 2024-04-08 11:36:27 +02:00
Craig Raw
af6171692b upgrade to hwi 3.0.0 2024-04-08 11:19:34 +02:00
Craig Raw
3c631fa653 add button to display seedqr on seed display dialog after warning 2024-04-05 13:50:05 +02:00
Craig Raw
10a796098b keep any existing seeds with matching fingerprints when changing a wallets output descriptor, rederiving the xpub if necessary 2024-04-05 12:11:46 +02:00
Craig Raw
8ac642b09c set default derivation for mnemonic and xprv imports to current keystore derivation 2024-04-04 14:05:41 +02:00
Craig Raw
33d9f260c4 close qr display dialog for current fresh address when it updates 2024-04-04 12:50:03 +02:00
Craig Raw
86247c6440 dynamically retrieve currently selected pool when tx0 preview is fetched 2024-04-04 12:15:33 +02:00
Craig Raw
c39425ed3b improve logging on tor cookie read errors 2024-04-04 10:41:32 +02:00
Craig Raw
e5e94c3ea6 compute latest block on open 2024-04-03 12:40:51 +02:00
Craig Raw
9ba4458f48 fix tor proxy switching for whirlpool client and mixing bip47 utxos 2024-04-02 17:14:19 +02:00
craigraw
a9fd7c263f
Merge pull request #1349 from zeroleak/whirlpool-1.0.5
whirlpool 1.0.5
2024-04-02 11:05:38 +02:00
zeroleak
6ef1313137 upgrade whirlpool-client 1.0.5 2024-04-01 16:32:56 +02:00
Craig Raw
8e66db0237 Merge branch 'master' into whirlpool-1.0.0 2024-03-29 10:12:11 +02:00
Craig Raw
6da98befe1 update drongo to latest 2024-03-29 10:10:51 +02:00
Craig Raw
6b4c301458 always bring first instance to foreground when second instance is closed 2024-03-29 09:36:11 +02:00
Craig Raw
86ff7b8cf9 optimize initial fee rates fetching by avoiding double server fee estimate and histogram calls where possible 2024-03-28 15:36:47 +02:00
Craig Raw
a805d9e036 use cached fee rate estimates on initial server connection if available, and retrieve updates from fee rate source immediately afterwards 2024-03-28 14:14:32 +02:00
Craig Raw
f0bfc44e72 avoid saving lock file link for default instance if environment variable is set 2024-03-28 12:01:48 +02:00
Craig Raw
0fad93524e delete existing instance lock file and recreate if client connection fails 2024-03-28 10:56:37 +02:00
Craig Raw
c1fc8712d5 use default sparrow home location in user dir for instance lock file pointer 2024-03-28 09:12:44 +02:00
Craig Raw
5d674b7e91 followup to handle situations where creating symlinks fails 2024-03-27 13:08:01 +02:00
Craig Raw
d1a353ae53 use unix sockets in sparrow home for instance checks and message passing, with system symlink to find existing instances for files and uris 2024-03-27 12:45:05 +02:00
Craig Raw
08ec158d19 support cookie authentication for tor control port 2024-03-26 13:26:32 +02:00
Craig Raw
2e8112cba0 add restart in different home folder to tools menu 2024-03-26 11:37:46 +02:00
Craig Raw
c108741b6f upgrade to pgpainless 1.6.7 with basic modules support 2024-03-26 10:04:05 +02:00
Craig Raw
4450d625dd external destination now checks for custom postmix handler 2024-03-25 17:01:31 +02:00
Craig Raw
2e1ee0c5b2 ensure whirlpool wallets use nfkd encoding for passphrases 2024-03-25 16:57:36 +02:00
Craig Raw
1c3c2d8089 cache tx0info and clear after tx0 broadcast 2024-03-25 16:23:24 +02:00
craigraw
a4991064af
Merge pull request #1333 from zeroleak/whirlpool-1.0.0-beta23
upgrade to whirlpool 1.0.1
2024-03-25 14:54:48 +02:00
Craig Raw
6ea6f4b5d2 add new wallet, open wallet and import wallet hyperlinks to background text shown when no tabs are open 2024-03-25 14:25:31 +02:00
Craig Raw
210d52c001 change unselected tabs to be lighter colored than selected tabs in dark theme 2024-03-25 13:05:16 +02:00
zeroleak
fcbed8795f upgrade whirlpool-client 1.0.1 2024-03-20 15:24:42 +01:00
zeroleak
0d9e798bb7 upgrade whirlpool-client 1.0.0-beta23 2024-03-19 15:01:15 +01:00
Craig Raw
9d0c35bc75 handle import of ur crypto-hdkey without source fingerprint 2024-03-18 07:48:55 +02:00
Craig Raw
f3c44e6f3e fix script display of uncompressed pubkeys 2024-03-16 08:08:18 +02:00
Craig Raw
14d0437424 indicate if disconnected on startup, and display status with instruction on how to connect for longer 2024-03-14 09:36:02 +02:00
Craig Raw
d2934c94c5 disable manifest field in download verify dialog if signature signs release file directly 2024-03-14 09:05:14 +02:00
Craig Raw
2e847199f5 fix message signing by qr with no action on scan qr 2024-03-14 08:49:52 +02:00
Craig Raw
c9d1650ed4 various updates and fixes 2024-03-13 15:58:43 +02:00
zeroleak
da74089969 upgrade to whirlpool 1.0.0-beta10 2024-03-11 02:12:56 +01:00
Craig Raw
d1ac5b076e avoid adding block explorer to transaction context menu when configured to none 2024-03-09 10:24:42 +02:00
Craig Raw
e1564217ed bump to v1.8.5 2024-03-07 20:16:35 +02:00
Craig Raw
2ef66d504f show pgp fingerprint in pgp verification signed by field tooltip 2024-03-07 08:21:46 +02:00
Craig Raw
f0bd07b4b7 fix tests with derivation paths matching other networks 2024-03-07 08:13:48 +02:00
Craig Raw
e43b783664 update build plugins to remove gradle warnings 2024-03-07 07:59:53 +02:00
Craig Raw
2d5e24366c revert to custom javafx gradle plugin to avoid monocle classnotfound issue 2024-03-07 07:50:23 +02:00
Craig Raw
195854fb6f bump to v1.8.4 2024-03-06 16:26:01 +02:00
Craig Raw
9e4eed965c show file name in invalid file dialog 2024-03-06 14:17:15 +02:00
Craig Raw
c034dbe89e better handling of multiple verification file drop 2024-03-06 14:11:37 +02:00
Craig Raw
4cb2e1ef9f show bbqr option for bip129 and file menu qr transaction display 2024-03-06 13:33:13 +02:00
Craig Raw
7258b049c9 followup 2024-03-06 12:40:57 +02:00
Craig Raw
5475a81e3a avoid disabling public key field when user provided key is used 2024-03-06 12:31:08 +02:00
Craig Raw
f003b6d732 improve asc file type description 2024-03-06 08:40:52 +02:00
zeroleak
249a01c208 upgrade to whirlpool 1.0.0-beta4 2024-03-05 18:32:20 +01:00
Craig Raw
c34a423f95 followup 2024-03-05 15:10:33 +02:00
Craig Raw
55e7689f7c open files and uris after initial wallet loading 2024-03-05 14:09:52 +02:00
Craig Raw
914afe9a8d perform gpg verification in separate thread 2024-03-05 12:52:37 +02:00
Craig Raw
81c7bc7ecb improvements to download verifier drag and drop 2024-03-05 12:23:11 +02:00
Craig Raw
3d18477560 add application/pgp-signature to handled mime types 2024-03-04 16:14:46 +02:00
Craig Raw
2c1f591c51 handle drop of manifest files as well 2024-03-04 15:30:25 +02:00
Craig Raw
d109eaa654 handle signature and manifest file mixups, add file handler for .asc files 2024-03-04 15:07:56 +02:00
Craig Raw
803e43cb45 if validating derivations, disallow paths that match other networks 2024-03-01 11:24:21 +02:00
Craig Raw
a45024ac70 followup 2024-03-01 10:06:30 +02:00
Craig Raw
0f05502af6 allow adding additional accounts (up to account 30) if accounts 0-9 have already been added 2024-03-01 09:51:23 +02:00
Craig Raw
6a496894e1 add import file to krux keystore and wallet import 2024-03-01 07:50:00 +02:00
Craig Raw
d3b0eac51a improve qr reading by additionally scanning using boofcv 2024-02-29 15:32:29 +02:00
Craig Raw
2cc02e38e6 add restart in signet to tools menu, add mempool.space signet public server 2024-02-28 14:52:52 +02:00
Craig Raw
ae29108656 cormorant: fix wallet loading with multiple ancestors in the mempool 2024-02-28 13:13:32 +02:00
Craig Raw
0ed8c6af7c improve script area display of inputs that spend a taproot script path 2024-02-28 10:29:14 +02:00
Craig Raw
6d7f02227a add seconds to date column for transactions and utxos csv export 2024-02-27 15:35:26 +02:00
Craig Raw
8f52039c7b followup 2024-02-27 15:27:26 +02:00
craigraw
f14e2fb020
Merge pull request #1281 from PrinceOfEgypt/master
add delay to rates service startup
2024-02-27 15:23:45 +02:00
PrinceOfEgypt
cc9a557a2e Configure startup delay by platform 2024-02-26 09:22:17 -06:00
Craig Raw
e50cc7126d make coldcard import and export functions scannable 2024-02-26 16:43:56 +02:00
PrinceOfEgypt
6cdbba4bb3
Merge branch 'sparrowwallet:master' into master 2024-02-26 08:41:54 -06:00
Craig Raw
7f3885611a add support for bbqr encoding and decoding 2024-02-26 11:41:10 +02:00
PrinceOfEgypt
17ea75603f Create separate constant for RatesService delay 2024-02-25 12:14:11 -06:00
PrinceOfEgypt
da1626070b Add 2 second delay to RatesService 2024-02-24 09:36:02 -06:00
Craig Raw
6f4d37d3ff confirm and close application before launching installer 2024-02-22 14:56:38 +02:00
Craig Raw
78b0c63f87 test for windows gpg home 2024-02-22 14:12:24 +02:00
Craig Raw
14d96bdb4e match gpg behaviour for loading user public keyrings 2024-02-22 13:50:58 +02:00
Craig Raw
d73820464e add download verification dialog supporting pgp signatures and optional sha256 manifests 2024-02-22 13:35:06 +02:00
Craig Raw
ff4ff90bb2 revert to java 18 due to windows code signing issue 2024-02-16 11:27:24 +02:00
Craig Raw
d2940265fd fix drongo typo 2024-02-16 08:11:24 +02:00
Craig Raw
3f72a84afe increase whirlpool http client timeout 2024-02-16 07:58:46 +02:00
Craig Raw
521bbdd70e revert to build on ubuntu 20.04 for xz compression 2024-02-15 16:50:05 +02:00
Craig Raw
15c3150a4f update to build on ubuntu 22.0.4 2024-02-15 13:09:22 +02:00
Craig Raw
865c52e5d9 followup 2024-02-15 11:39:46 +02:00
Craig Raw
ee195f2677 upgrade build to java 21 2024-02-15 10:32:29 +02:00
Craig Raw
1d50b4f296 upgrade junit tests from 4 to 5 2024-02-15 10:26:29 +02:00
Craig Raw
22310cd8c9 update guava, gson, junit, nightjar and slf4j dependencies 2024-02-15 09:24:21 +02:00
Craig Raw
78406fd024 include labels for hidden addresses in address csv export 2024-02-07 09:26:26 +02:00
Craig Raw
74a551ed01 add support for satschip nfc card 2024-02-07 09:19:55 +02:00
craigraw
4de6bd5e83
Merge pull request #1256 from rex4539/typos
fix typos
2024-02-05 07:42:21 +02:00
Dimitris Apostolou
8d6230e834
Fix typos 2024-02-02 23:11:36 +02:00
Craig Raw
31042039d7 upgrade to hwi 2.4.0 2024-02-01 10:45:19 +02:00
Craig Raw
1ba501f5c8 add trezor safe 3 support (hwi update still required) 2024-01-30 09:37:00 +02:00
Craig Raw
55d5a97d99 avoid saving certificates for public servers 2024-01-30 08:03:38 +02:00
Craig Raw
37af663511 update gradle wrapper to 8.5 2024-01-29 14:35:58 +02:00
Craig Raw
b757c76028 update drongo build with java 21 2024-01-29 12:21:29 +02:00
craigraw
82e0b63ed0
Merge pull request #1244 from msgilligan/msgilligan-gradle-jlink-3.0.1
update jlink plugin to build with java 21
2024-01-29 12:20:21 +02:00
Sean Gilligan
7da3a55489 build.gradle: Update to JLink plugin 3.0.1
This adds support for building with JDK 21.
2024-01-24 10:27:25 -08:00
Craig Raw
7ebb92d90a crop y axis range in block target fee rates chart 2024-01-24 09:21:04 +02:00
Craig Raw
1d32b69345 add border to expanded transaction diagram on linux to handle some window managers 2024-01-22 09:52:32 +02:00
Craig Raw
fbc49fd6f5 change cancel button text to close on qr display and scan dialogs 2024-01-22 09:15:55 +02:00
Craig Raw
30001051c7 avoid null labels when broadcasting a loaded transaction with no name 2024-01-22 09:12:00 +02:00
Craig Raw
1e74ae5f19 bump to v1.8.3 2024-01-18 13:01:59 +02:00
Craig Raw
6fc52fdc0e display effective fee rate next to transaction fee rate when constructing a cpfp tx 2024-01-18 09:53:44 +02:00
Craig Raw
1d2081d2a6 transaction tree label tweaks 2024-01-18 09:00:39 +02:00
Craig Raw
4d587cf776 update keystone logo 2024-01-18 08:35:01 +02:00
Craig Raw
14689dd256 add payment labels to transaction tabs for received outputs where available from open wallets 2024-01-17 14:43:24 +02:00
Craig Raw
c8a5c1fb86 followup 2024-01-17 13:50:04 +02:00
Craig Raw
734818cd9e adjust transaction tree width for input labelling 2024-01-17 13:43:48 +02:00
Craig Raw
6aa5473b24 add rescan note to warning tooltip for large gap limits 2024-01-17 12:33:55 +02:00
Craig Raw
1b460533f5 followup to reducing server calls on loading transaction tab 2024-01-17 10:48:57 +02:00
Craig Raw
ad1ecfb887 fix changing amount bitcoin unit on send tab with comma decimal separator 2024-01-16 12:19:56 +02:00
Craig Raw
6a5060c0c8 cormorant: support coinbase transactions 2024-01-15 17:20:44 +02:00
Craig Raw
02a0a3277b reduce server calls on opening a transaction tab by using open wallet history when performing spent output lookup 2024-01-12 15:35:26 +02:00
Craig Raw
57dba5d6ae improve input and output labels on transaction tree and detail panels 2024-01-12 12:10:38 +02:00
Craig Raw
540424a2e3 followup 2024-01-10 11:18:26 +02:00
craigraw
e1d9d54da3
Merge pull request #1204 from krzyczak/master
toggle bitcoin unit on clicking a bitcoin value label
2024-01-10 11:15:47 +02:00
Craig Raw
2b8a513adf support reading revised ur tags including v3 output descriptors 2024-01-10 11:05:03 +02:00
Craig Raw
20df1dbd8b fix typo 2024-01-09 11:59:19 +02:00
Craig Raw
04c8017bb5 increase payments tab header width on send tab 2024-01-09 11:58:38 +02:00
Craig Raw
be0ac52a09 support creating wallets from descriptors containing master xprvs 2024-01-09 11:38:10 +02:00
Craig Raw
3162371838 fix order of outputs displayed in transaction diagram where there are multiple payments with the same address and amount 2024-01-08 12:12:13 +02:00
Craig Raw
b5196d1ac2 encrypt electrum wallet exports including private keys where password is available 2024-01-08 09:16:55 +02:00
krzyczak
343cb2271c feat: Implement bitcoin unit toggle on bitcoin balance 2023-12-22 17:29:22 +00:00
Craig Raw
4feb4a3a79 add airgap vault to airgapped options (draft) 2023-12-06 08:55:09 +02:00
craigraw
20b4d5a1b5
Merge pull request #1187 from Toporin/patch-satochip-error-0x9C01
satochip: flush card cache where cache memory is full
2023-12-01 09:12:58 +02:00
Craig Raw
06489d8339 cormorant: round up wallet range to avoid frequent rescans with a large gap limit 2023-12-01 09:09:28 +02:00
Toporin
e368782b4c Satochip: Patch error 0x9C01 in cardBip32GetExtendedKey()
This error occurs when the cache memory in the card is full.
We need to flush the cache by sending the same command with the p2 flag set to 0x80.
2023-11-30 21:25:01 +01:00
Craig Raw
6ee3755ce4 cormorant: fix descriptor range calculation and extend range from pruned date only where necessary 2023-11-30 12:54:50 +02:00
Craig Raw
d425933189 only allow sweeping from uncompressed keys to legacy script type addresses 2023-11-30 11:42:59 +02:00
Craig Raw
675b7ed4f8 cormorant: default to existing descriptor timestamp when extending range 2023-11-30 08:50:20 +02:00
Craig Raw
6072f6d31a add master fingerprint to passphrase entry dialog in terminal 2023-11-29 10:25:40 +02:00
Craig Raw
e1fb674170 avoid showing balance in fiat when exchange source is none 2023-11-29 09:32:01 +02:00
Craig Raw
d8839763a4 remove extraneous space character 2023-11-23 07:49:20 +02:00
Craig Raw
64e98cdb35 bump to v1.8.2 2023-11-22 11:59:02 +02:00
Craig Raw
bde8fef35e disable rename wallet on transaction tab 2023-11-22 09:17:17 +02:00
Craig Raw
87e7a87e5e exchange source: use closing price where available 2023-11-22 08:12:32 +02:00
Craig Raw
85eb4df7e9 transaction tab: rearrange via transaction tree 2023-11-22 07:59:26 +02:00
Craig Raw
bb32a1e7b1 add comment to transactions csv on the approximate nature of the fiat values 2023-11-21 17:59:13 +02:00
Craig Raw
f590794589 add qr display (with save to pdf) to output descriptor wallet export options 2023-11-21 10:06:32 +02:00
Craig Raw
1e3ce7eb88 add historical fiat values to transactions csv export 2023-11-21 09:33:47 +02:00
Craig Raw
ef3e2ed695 cormorant: handle checking imports and stopping before started 2023-11-20 10:56:06 +02:00
Craig Raw
74c3370277 use fallback fee rate estimates if the connected server returns an error estimating fee rates 2023-11-20 10:29:14 +02:00
Craig Raw
170e7c0abf reduce http error logging 2023-11-17 09:43:22 +02:00
Craig Raw
e0486ff4a9 followup 2023-11-16 18:12:19 +02:00
Craig Raw
1d17384152 increase and decrease maximum on fee rate slider where fee rate equals or exceeds current range, set testnet fallback fee rate to 1 sat/vb 2023-11-16 16:58:24 +02:00
Craig Raw
aec26d512b add oxt.me as fee rates source 2023-11-16 10:12:41 +02:00
Craig Raw
9e5a6e83d1 set smaller increment amount on fee range slider for key adjustments 2023-11-15 16:22:12 +02:00
Craig Raw
eecb90e9b2 cormorant: check wallet gap limit and increase core descriptor range if necessary 2023-11-15 13:29:26 +02:00
Craig Raw
b3516063b2 add null safe tests for standard account types 2023-11-15 10:52:05 +02:00
Craig Raw
1d560d6aa6 save mix to wallet index to improve handling of mix out failures 2023-11-15 10:20:58 +02:00
Craig Raw
d128401a09 fix transaction diagram output size indicators in transactions with non-address outputs 2023-11-14 15:18:47 +02:00
Craig Raw
9391a397da set dialog minimum height to preferred height (or make resizable) to avoid window sizing bug in some linux environments 2023-11-14 14:29:28 +02:00
Craig Raw
a5312374a8 show error dialog on cpfp if no outputs are spendable 2023-11-13 14:37:41 +02:00
Craig Raw
c81c42a87c switch from httpurlconnection to jetty http client to avoid spurious dns query 2023-11-10 19:16:03 +02:00
Craig Raw
d84ade5b7d add krux to wallet import options 2023-11-10 14:55:00 +02:00
Craig Raw
323d29e34a bump to v1.8.1 2023-11-09 14:37:35 +02:00
Craig Raw
95cb8c4b2c bump to v1.8.0 2023-11-09 12:55:10 +02:00
Craig Raw
2712555c72 followup 2 2023-11-09 12:50:39 +02:00
Craig Raw
72066395d6 followup 2023-11-09 12:31:18 +02:00
Craig Raw
7b3e5f37b9 improve vertical layout sizing on transaction tabs 2023-11-09 12:17:08 +02:00
Craig Raw
870a468584 add bisq segwit misderivation to mnemonic wallet discovery 2023-11-09 09:31:02 +02:00
Craig Raw
0340cba441 fix improvement to renaming wallet keystore labels for uniqueness 2023-11-09 09:10:54 +02:00
Craig Raw
91f845cbbf followup 2023-11-08 17:23:30 +02:00
Craig Raw
d0f21eafd1 schedule regular check to indicate proxy status in sparrow terminal 2023-11-08 17:08:44 +02:00
Craig Raw
36c2181a7f remove keyboard shortcut+pageup/pagedown to switch tabs already added by default 2023-11-08 15:22:39 +02:00
Craig Raw
218e751333 display error message when attempting to mix from account 0 and it is not the master wallet 2023-11-08 12:29:54 +02:00
Craig Raw
995d2c5e4e temporarily disconnect from whirlpool if gap limit is increasing rapidly 2023-11-08 11:59:19 +02:00
Craig Raw
0d7ae74f0f fix whirlpool wallets with special characters in the passphrase 2023-11-08 09:27:25 +02:00
Craig Raw
e6eea67c4b add search all open wallets functionality, include matches on transaction output addresses 2023-11-07 16:38:33 +02:00
Craig Raw
ea3e0ca34a select all text in message sign signature field on mouse click 2023-11-07 15:16:06 +02:00
Craig Raw
3fedd8eb43 support opening multiple wallet and transaction files at once 2023-11-07 14:14:14 +02:00
Craig Raw
30408af719 add fee rate slider to private key sweep dialog 2023-11-07 14:02:17 +02:00
Craig Raw
910a400b18 fix computation of master fingerprint for satochip 2023-11-07 11:58:18 +02:00
Craig Raw
f4ac18c3e1 increase gap limit where necessary to sign a psbt where global xpubs match 2023-11-02 12:38:51 +01:00
Craig Raw
cb06e1aaf7 freeze and unfreeze utxos in sparrow terminal by pressing f on utxos table 2023-10-31 11:20:10 +01:00
Craig Raw
d881e47ec9 indicate if proxy is enabled in sparrow terminal 2023-10-31 10:37:19 +01:00
Craig Raw
dbafefb940 reload cormorant wallet if unloaded when polling bitcoind 2023-10-31 10:21:33 +01:00
Craig Raw
ee6589991d add initial satochip card support 2023-10-31 09:56:31 +01:00
Craig Raw
24578dcf88 enlarge qr display dialog and increase default qr code density 2023-10-24 17:12:41 +02:00
Craig Raw
ddae1a12d8 add keyboard shortcut ctrl+pageup/pagedown to switch tabs on windows/linux 2023-10-24 15:34:20 +02:00
Craig Raw
1c9b6c3eef add keyboard shortcut ctrl/cmd+alt+arrow to switch tabs 2023-10-24 15:06:21 +02:00
Craig Raw
158ecd4ab1 fix thread race issue when connecting to cormorant electrum server 2023-10-24 10:51:09 +02:00
Craig Raw
b6bcdef712 minor fixes 2023-10-22 14:43:09 +02:00
Craig Raw
6eefd3f182 add duplicate payment address warning to transaction diagram 2023-10-12 12:37:44 +02:00
Craig Raw
9280504f70 add duplicate payment address warning to transaction diagram 2023-10-12 12:37:41 +02:00
Craig Raw
a6a671f687 remove block hash from transaction tab fields, add to context menu for block height and timestamp 2023-10-11 13:08:42 +02:00
Craig Raw
4e3e8b7cc4 add figure caption to overview diagram on transaction tab to describe transaction 2023-10-11 11:44:40 +02:00
Craig Raw
2b8fc3900a followup 2023-10-06 13:50:44 +02:00
Craig Raw
cff731dec7 ignore script type change warning when replacing wallet in settings 2023-10-05 12:37:27 +02:00
Craig Raw
c9c0c35964 add krux as airgapped hww 2023-10-05 10:35:55 +02:00
Craig Raw
afed5c65f5 add additional testnet public server qtornado.com 2023-10-05 08:53:12 +02:00
Craig Raw
0724c38582 add whirlpool postmix to the list of possible accounts that can be added to any legacy or segwit wallet 2023-09-29 14:37:58 +02:00
Craig Raw
31539a27ac improve fullscreen behaviour by setting dialog ownership to parent window 2023-09-29 12:02:14 +02:00
Craig Raw
201900aa0e add airgapped message signing via qr 2023-09-25 13:39:51 +02:00
Craig Raw
3dcbe34485 add scan button to qr display dialog to progress immediately to scanning 2023-09-21 14:07:58 +02:00
Craig Raw
31842cc0f2 update caravan and specter logos 2023-09-21 11:34:04 +02:00
Craig Raw
f85349bd36 support hex border wallets grid pdf 2023-09-19 14:05:26 +02:00
Craig Raw
a18c24e19f add /usr/lib64 as possible location for linux pcsc lib 2023-09-18 10:44:33 +02:00
Craig Raw
e23c1b3872 force selection of a new configured server if currently configured server is deleted 2023-09-04 09:47:41 +02:00
Craig Raw
3c2ef43526 bump to v1.7.10 2023-08-31 13:36:36 +02:00
Craig Raw
c81aae0c6a minor account renaming bug fixes 2023-08-31 10:54:16 +02:00
Craig Raw
ff1a9e8a52 add or remove tooltip on account tab on renaming 2023-08-31 10:06:46 +02:00
Craig Raw
90bfa47046 add jade multisig wallet import 2023-08-31 09:34:15 +02:00
Craig Raw
6383b8b46f improve renaming wallet keystore labels for uniqueness 2023-08-30 15:21:43 +02:00
Craig Raw
76dc294748 support import and export of keystore labels in crypto-output qr codes 2023-08-30 14:22:18 +02:00
Craig Raw
0bc1dd96ed use port instead of unix socket for internal tor control to avoid bug where unix socket path is too long 2023-08-30 10:39:52 +02:00
Craig Raw
73d2d3cbbc replace hwi 2.3.1 binary with macos 10.13 compiled version 2023-08-30 09:55:08 +02:00
Craig Raw
d5fdd6881c followup 2023-08-30 09:32:02 +02:00
Craig Raw
b1bc25ba04 propagate transaction label changes to inputs, outputs and addresses where their existing labels were set in the same manner 2023-08-29 16:03:46 +02:00
Craig Raw
2c1204c247 add treetable config classes 2023-08-23 13:52:33 +02:00
Craig Raw
05a1fd8e8d refactor cointreetable column sorting, add default sorting if table was empty 2023-08-23 12:18:02 +02:00
Craig Raw
97f21394a7 add tooltip to account tab where label is truncated 2023-08-23 11:24:24 +02:00
Craig Raw
c57a445046 add mempoolfullrbf config variable to enable rbf functionality on mempool transactions without checking sequence flags (default false) 2023-08-17 16:18:27 +02:00
Craig Raw
fb981f1548 suggest clearing any existing keystores when script type is changed in settings 2023-08-17 15:35:33 +02:00
Craig Raw
407dde2703 upgrade to hwi 2.3.1 2023-08-17 12:33:03 +02:00
Craig Raw
f175139fd3 add wallet summary dialog 2023-08-14 16:11:55 +02:00
Craig Raw
bebd7eebe5 handle core bug where listwalletdir returns empty results 2023-08-14 10:02:35 +02:00
Craig Raw
e01f6b42b1 add fulcrum.sethforprivacy.com public mainnet electrum server 2023-08-07 13:57:27 +02:00
Craig Raw
bd1c6c076e avoid adding inputs when constructing a consolidation transaction replacement, allowing output to decrease 2023-08-07 12:54:29 +02:00
Craig Raw
46b1bd2fd2 improve address resolution error message 2023-08-07 12:20:07 +02:00
Craig Raw
2d7c5dcec7 terminal: check if theme is present to avoid exception on utxo history update 2023-08-07 12:00:58 +02:00
Craig Raw
93bcf6cef9 fix single character multisig threshold parsing issue 2023-07-23 13:13:30 +02:00
Craig Raw
4b6a03ef56 for zbar scans, return scanned characters as raw bytes 2023-07-23 12:48:04 +02:00
Craig Raw
7ff4230e13 bump to v1.7.9 2023-07-18 16:22:43 +02:00
Craig Raw
7d7967ec00 improve handling of invalid bip322 signatures 2023-07-18 12:25:39 +02:00
Craig Raw
b0883f034b change type and name of enable zbar config variable 2023-07-18 11:49:25 +02:00
Craig Raw
f88628c469 terminal: set scrollbar to top if scrolled below new utxo table row count 2023-07-18 10:58:11 +02:00
Craig Raw
ac7a964edf make alert dialogs resizable 2023-07-18 10:37:27 +02:00
Craig Raw
7bb22419df add rename wallet menu command 2023-07-18 10:09:37 +02:00
Craig Raw
c443bc78d3 add taproot script type to connected wallet import options 2023-07-17 11:57:34 +02:00
Craig Raw
a5519537c5 add tooltip to save final transaction button to suggest connecting to a server in order to broadcast 2023-07-17 11:49:17 +02:00
Craig Raw
ef67d1f33b show warning dialog on submit bug report to redirect users to support where appropriate 2023-07-17 11:44:16 +02:00
Craig Raw
31bd64f821 show warning in preferences if currencies could not be retrieved from exchange rate source 2023-07-17 11:17:16 +02:00
Craig Raw
4c408ac7b1 make send to many dialog non-modal, menu command brings an existing dialog to foreground 2023-07-17 10:52:01 +02:00
Craig Raw
30a9c1208a add config variable to disable zbar scanning 2023-07-17 08:48:34 +02:00
Craig Raw
96fd824a3e add zbar qr reader for all qr scans 2023-07-14 15:04:27 +02:00
Craig Raw
0a469a380b when constructing rbf and cpfp transactions, add any additional utxos by output group if effective fee is sufficient 2023-07-11 11:25:29 +02:00
Craig Raw
5e3f31de30 followup 2023-07-11 10:07:00 +02:00
Craig Raw
af9fb8694e set initial fee for rbf tx to satisfy minimum relay requirements 2023-07-11 09:50:19 +02:00
Craig Raw
ebfdfc0c9f use txo filters for all wallet transaction output filtering, fixing overselection of inputs during rbf 2023-07-11 09:08:40 +02:00
Craig Raw
c9d6bb350d rearrange recent files list when tabs are moved left or right 2023-07-05 09:59:22 +02:00
Craig Raw
f980516462 various minor improvements 2023-07-05 09:48:48 +02:00
Craig Raw
795892f7c9 show error for bip322 multisig signatures 2023-07-04 09:22:15 +02:00
Craig Raw
9576581d89 add bip322 message signing for singlesig addresses including p2tr 2023-07-04 08:51:38 +02:00
Craig Raw
296223130e cormorant: optimize memory used for calculating fee rate histogram 2023-06-30 09:00:37 +02:00
Craig Raw
87e2da0e01 followup 2023-06-26 12:25:55 +02:00
Craig Raw
9156ea1114 preserve payment labels when using rbf on multiple payment transactions 2023-06-26 12:15:58 +02:00
Craig Raw
b8fc2fd59e only keep older mempool histogram entries at ten minute intervals 2023-06-26 10:45:16 +02:00
Craig Raw
ef9723ed44 add additional utxos to cpfp transaction if output value is below dust threshold 2023-06-23 13:39:38 +02:00
Craig Raw
5105b503ea set transaction label to comma separated list of payment labels when multiple payments are made 2023-06-23 10:30:51 +02:00
Craig Raw
9bcb34e7d1 retain utxo frozen status on wallet refresh 2023-06-23 09:55:24 +02:00
Craig Raw
185a17edce bump to v1.7.8 2023-06-22 12:56:29 +02:00
Craig Raw
f8fa7f4cf2 add separate button to backup tapsigner without changing pin 2023-06-22 10:41:59 +02:00
Craig Raw
bcbb531414 fix ui button update when stopping mixing on terminal 2023-06-22 09:10:52 +02:00
Craig Raw
2deab05c45 remove path from non-batched electrum server requests 2023-06-22 08:21:57 +02:00
Craig Raw
c7923300c6 update kmp-tor to v1.4.3, fix storage tests 2023-06-22 07:54:48 +02:00
Craig Raw
c4651025be format fee rates in broadcast errors using unit format 2023-06-21 16:42:57 +02:00
Craig Raw
2897f88c8b fix unit format switching on send tab 2023-06-21 15:47:21 +02:00
Craig Raw
ee20a6980b fix incorrect hours check for 24h mempool fee rates toggle 2023-06-21 11:40:04 +02:00
Craig Raw
721c446fa8 update seedsigner help text 2023-06-21 10:36:34 +02:00
Craig Raw
7c43ee7208 add context menu options to date column in utxos table to freeze utxo and view transaction 2023-06-21 09:33:42 +02:00
Craig Raw
c6ea37e081 add 24h mempool fees chart toggle 2023-06-21 08:33:05 +02:00
Craig Raw
171bf24133 optimize fetching mempool entries for fee histogram when connected to bitcoin core, fix and improve mempool fee rates chart 2023-06-20 16:15:19 +02:00
Craig Raw
3242f00812 improve performance on large wallets with high address reuse 2023-06-15 12:19:09 +02:00
Craig Raw
15500b6535 highlight csv download buttons 2023-06-13 14:13:44 +02:00
Craig Raw
700c880b92 improve error message on broadcasting a tx with fee rate below the purge rate for the connected mempool 2023-06-11 10:50:19 +02:00
Craig Raw
7f2c07c918 set all witness utxos on psbt inputs before attempting to sign sweep tx 2023-06-08 11:07:32 +02:00
Craig Raw
0745d21761 fix export to specter desktop without any wallet history 2023-06-08 08:58:43 +02:00
Craig Raw
50aa9b4dcb ensure canonical ordering of key expressions in multisig descriptor qr 2023-06-07 10:21:29 +02:00
Craig Raw
fe50cb845e log batch errors getting fee estimates from server 2023-06-05 11:49:10 +02:00
Craig Raw
f4b9807285 widen mnemonic grid dialog to accommodate word numbers 2023-06-04 10:02:55 +02:00
Craig Raw
f534beb624 add additional rbf tx inputs if needed as required fee is increased 2023-06-01 15:31:15 +02:00
Craig Raw
719cfaa906 support airgapped keystore import of a tapsigner with a custom derivation path 2023-05-31 15:50:06 +02:00
Craig Raw
98d9a6882b look for supported cards across all connected card terminals 2023-05-31 14:25:45 +02:00
Craig Raw
dbbeaf67b6 support spendable property on utxos in bip329 wallet label imports and exports 2023-05-31 10:42:32 +02:00
Craig Raw
2a542bb8b9 avoid multiple selection on border wallets grid, display word number, clarify recovery phrase purpose on pdf 2023-05-30 14:01:45 +02:00
Craig Raw
29b630f6bf update to hummingbird v1.6.7 to support pair path components and unique part progress indicator 2023-05-29 17:20:43 +02:00
Craig Raw
3aa00076c6 trim leading and trailing whitespace from pay to address field 2023-05-25 16:15:38 +02:00
Craig Raw
3cf99961d3 improve error message when broadcasting an rbf transaction with insufficient fee, indicate minimum required fee if available 2023-05-25 14:48:32 +02:00
Craig Raw
742727d6f2 add ctrl/cmd+b keyboard shortcut for quickly switching from btc to satoshis and back 2023-05-25 13:22:44 +02:00
Craig Raw
5d99eee89a invert cropped frame and scan for inverted qrs 2023-05-25 12:29:43 +02:00
Craig Raw
b52be27a99 support scanning text qr containing seed words 2023-05-15 15:49:39 -05:00
Craig Raw
e68f177e4a bump to v1.7.7 2023-04-14 18:31:10 -06:00
Craig Raw
e0a6626650 add further guidance when regenerating a border wallets grid 2023-04-14 16:41:47 -06:00
Craig Raw
1d8888bb14 fix button placement on border wallets dialog in windows 2023-04-14 15:59:24 -06:00
Craig Raw
d0958b7936 clear border wallets selected cells on grid initialization 2023-04-14 13:26:29 -06:00
Craig Raw
d9d316a627 bump to v1.7.6 2023-04-14 10:25:08 -06:00
Craig Raw
0270910b74 update specter desktop wallet import 2023-04-14 08:29:24 -06:00
Craig Raw
432e0642ca cormorant: improve error message when core wallet support is disabled 2023-04-14 07:50:06 -06:00
Craig Raw
c7ab8e4601 support broadcasting via mempool.space when using signet 2023-04-14 07:41:00 -06:00
Craig Raw
1a46f8a643 preserve order of cell selection in border wallets grid 2023-04-14 06:36:34 -06:00
Craig Raw
04145bde74 add border wallets number grid support 2023-04-06 14:57:56 +02:00
Craig Raw
483e4c8f38 always show tapsigner in airgapped import options 2023-04-05 08:24:18 +02:00
Craig Raw
fe4468d49d bump to v1.7.5 2023-04-05 08:10:36 +02:00
Craig Raw
a4e9ef989d darker dark theme and other styling improvements 2023-04-04 12:33:12 +02:00
Craig Raw
4ed8550f1d fix dark themed spreadsheets 2023-04-04 09:22:49 +02:00
Craig Raw
4bec71e7c4 read name from ur:crypto-hdkey and set keystore label if present 2023-03-29 16:00:32 +02:00
Craig Raw
961fd94dd6 allow stowaway counterparty to spend postmix utxos but receive to master wallet 2023-03-29 15:17:19 +02:00
Craig Raw
7915bbfa47 save border wallet pdf when generating a new recovery phrase 2023-03-29 12:00:04 +02:00
Craig Raw
49e70e8e9b fix accidentally making keystore fields editable on loading of single account non-watchonly wallets 2023-03-29 09:22:45 +02:00
Craig Raw
6063b02113 generate border wallets grid from seed words 2023-03-29 08:21:15 +02:00
Craig Raw
faa5a11c94 update tor to 0.4.7.13 using kmp-tor library 2023-03-28 14:38:20 +02:00
Craig Raw
acab50cdcd add diynodes.com public mainnet electrum server 2023-03-28 09:15:38 +02:00
Craig Raw
4d7d897e06 add port of deterministic prng for border wallets word shuffle 2023-03-28 08:37:20 +02:00
Craig Raw
af532e7fc9 import seed via border wallets grid pattern 2023-03-27 11:00:32 +02:00
Craig Raw
fd2b383dbc autosuggest possible words for the last word in a bip39 seed 2023-03-22 15:50:12 +02:00
Craig Raw
98b33e184e use whirlpool premix priority slider to change mixfeetarget, display warning if chosen fee rate is much lower than normal priority 2023-03-21 09:34:19 +02:00
Craig Raw
3bc7c7473a fix error initializing whirlpool on new wallet without a passphrase 2023-03-21 08:34:06 +02:00
Craig Raw
4f6981b869 change wallet gap limit and subscribe to new addresses if an address beyond gap limit range is requested 2023-03-15 08:44:53 +02:00
Craig Raw
258d46a253 support saving tapsigner backup as binary file 2023-03-09 13:40:57 +02:00
Craig Raw
40a3eb5d4f followup, minor tidying 2023-03-09 11:01:25 +02:00
craigraw
35965235f3
Merge pull request #860 from wazint/master
Add context menu to copy amount values from amount cells
2023-03-09 10:55:36 +02:00
Craig Raw
368b24ea3b cormorant: handle empty (0 block only) chains 2023-03-09 10:09:52 +02:00
Craig Raw
107b5ba36c show psbt qrs without non witness utxo entries for segwit signing wallets 2023-03-07 13:17:33 +02:00
Craig Raw
84978a3d5d use different addresses when sending batched payments to the same paynym 2023-03-06 12:30:26 +02:00
Craig Raw
dd3b980c36 strip path from server url when determining host and port 2023-03-06 10:40:46 +02:00
wazint
b9a553abf2 don't initialise a ContextMenu for each cell update 2023-03-04 20:30:47 +02:00
Craig Raw
48b3dbc353 bump to v1.7.4 2023-03-01 10:43:36 +02:00
Craig Raw
fb40d991bb replace default textinput dialog with custom textfield dialog 2023-03-01 08:14:25 +02:00
Craig Raw
5fe6a7196a use uppercase to encode pdf output descriptor qr 2023-02-28 16:52:42 +02:00
Craig Raw
f06b859c82 extend request timeout for paynym api 2023-02-28 14:37:46 +02:00
Craig Raw
80dd59928e upgrade jlink plugin to support java 19 2023-02-28 14:21:57 +02:00
Craig Raw
f22f76464a add option ot disable block explorer 2023-02-28 13:59:45 +02:00
Craig Raw
dfe1f16495 configure a block explorer url, and open a txid in the configured block explorer 2023-02-28 13:19:24 +02:00
Craig Raw
90a9030ecb allow crypto-output qr scanning from wallet import dialog 2023-02-27 14:03:12 +02:00
Craig Raw
4ab33a373c reduce log level for no card reader errors 2023-02-27 12:36:41 +02:00
Craig Raw
10e751d6e1 upgrade to hwi 2.2.1 2023-02-27 12:18:50 +02:00
Craig Raw
5f40669af7 request confirmation before enabling use of a bip39 passphrase 2023-02-27 09:11:06 +02:00
wazint
97b4ed48db add context menu to copy amount values from amount cells 2023-02-24 16:55:40 +02:00
Craig Raw
9fc096569a add minfeerate parameter to bip78 payjoin urls 2023-02-23 12:53:12 +02:00
Craig Raw
41636f7152 support encrypted bip129 wallet imports 2023-02-23 12:17:41 +02:00
Craig Raw
fc5d48de6f bip129 round 2 support (wallet import and export) 2023-02-23 12:02:06 +02:00
Craig Raw
2a7f14a4ed bip129 round 1 support with optional signing of bsms keystore exports 2023-02-22 10:22:04 +02:00
Craig Raw
1cb6778502 bump required java version to 18 2023-02-20 11:56:34 +02:00
Craig Raw
7f254e763d fix keystore encryption issue when changing the password on a wallet with freshly added accounts 2023-02-17 09:10:40 +02:00
Craig Raw
e0ff42b6a4 terminal: add lock menu item to all wallets with a password 2023-02-17 08:37:10 +02:00
craigraw
88fc8f5017
Merge pull request #843 from secondl1ght/master
add StartupWMClass to linux .desktop file
2023-02-15 08:00:36 +02:00
secondl1ght
d7072928de
add StartupWMClass to linux .desktop file 2023-02-14 21:22:41 -07:00
Craig Raw
0cc9ddba05 read and throw hwi error stream if stdout empty 2023-02-14 09:57:23 +02:00
Craig Raw
e3799cd0a8 fix error receiving a stowaway to postmix by reverting to master wallet 2023-02-13 18:27:21 +02:00
Craig Raw
ea6b30326e fix taproot signature hash for single | anyonecanpay 2023-02-13 17:35:17 +02:00
Craig Raw
38768885e2 show hwi signature verification errors, display strings encoded into scripts 2023-02-13 16:36:33 +02:00
Craig Raw
c360177c31 use default ports for bitcoin core if absent, fix ux on changing port for an aliased server 2023-02-12 13:06:21 +02:00
Craig Raw
e88ea0bac1 improve ux of bip39 wallet discovery on bitcoin core 2023-02-12 11:48:46 +02:00
Craig Raw
a66b36c59c cormorant: switch bitcoind client from named to array parameters to support btc-rpc-proxy 2023-02-11 14:28:10 +02:00
Craig Raw
fb3b674b65 recompile secp256k1 on osx 10.13.6 2023-02-10 09:23:02 +02:00
Craig Raw
eff0e201f3 cormorant: only use proxy when connecting to onion addresses 2023-02-10 09:12:09 +02:00
Craig Raw
58d10cbba4 v1.7.3 2023-02-09 18:30:28 +02:00
Craig Raw
29bce8a9bc revert to ubuntu 20.04 runner 2023-02-09 17:34:19 +02:00
Craig Raw
67dcf69a78 v1.7.2 2023-02-09 13:18:30 +02:00
Craig Raw
7ad8a04bda improve legacy core wallet error messages 2023-02-09 13:14:03 +02:00
Craig Raw
24e75603c6 update categories for linux desktop installation 2023-02-09 10:53:45 +02:00
Craig Raw
545342dfb4 disable server toggle when no server is configured (url host is empty) 2023-02-09 10:29:19 +02:00
Craig Raw
b15d6308bd write and parse both multipath and single descriptors in wallet output descriptor export 2023-02-09 09:06:30 +02:00
Craig Raw
ff0c381437 cormorant: find cookie dir for non-mainnet networks 2023-02-08 14:10:20 +02:00
Craig Raw
555260e954 implement bip329 for importing and exporting wallet labels 2023-02-08 08:03:06 +02:00
Craig Raw
8d584d1c48 followup for aarch64 2023-02-07 09:13:32 +02:00
Craig Raw
967cf0cdfa try to locate pcsc library on linux before searching for card terminals 2023-02-06 16:44:34 +02:00
Craig Raw
41ba8455a0 cormorant: avoid importing wallets when testing connection, only show prune warning once per connection 2023-02-06 13:25:35 +02:00
Craig Raw
d84f3bf887 add config property autoSwitchProxy to disable automatic proxy switching on failure, and improve tor connection failure message 2023-02-06 11:38:19 +02:00
Craig Raw
153815d9e3 indicate in ssl handshake warning that a certificate renewal may be the cause 2023-02-06 11:01:42 +02:00
Craig Raw
0250579445 tapsigner: change card backup to base64 2023-02-06 10:56:53 +02:00
Craig Raw
7590d786b5 fix copy address output script bytes to return entire scriptpubkey 2023-02-03 13:57:29 +02:00
Craig Raw
55809b7dc3 decrypt keystore before requesting passphrase to show masterfingerprint 2023-02-03 08:20:09 +02:00
Craig Raw
06026b0a09 further improvements on wallet importing wrt pruned nodes 2023-02-01 14:30:24 +02:00
Craig Raw
2cd64aa650 improve handling of scan dates earlier than core pruned date 2023-02-01 13:48:20 +02:00
Craig Raw
0b980f6ab5 satscard: retrieve private keys for previously used slots 2023-02-01 11:37:36 +02:00
Craig Raw
73dcef9fd1 followup: add or remove card option from pay to dropdown as reader becomes available 2023-02-01 10:07:59 +02:00
Craig Raw
4e3491ec64 tapsigner and satscard initialization fixes, satscard address and private key retrieval, core address scanning support 2023-02-01 09:39:49 +02:00
Craig Raw
176e440195 unseal satscard functionality added to sweep private key dialog 2023-01-31 09:30:53 +02:00
Craig Raw
300545b289 refactor cardapi to generic service 2023-01-30 14:47:00 +02:00
Craig Raw
057a9efb1f cormorant: fix initialisation of sent txes without txindex 2023-01-30 14:46:12 +02:00
Craig Raw
9edeff9aab cormorant: set wallet to load on bitcoind startup, check if loaded first 2023-01-30 12:28:07 +02:00
Craig Raw
f938506a3f add tapsigner message signing support 2023-01-30 09:41:12 +02:00
Craig Raw
4fb8c5a61b add card scan to hwi enumeration and refactor device pane 2023-01-27 13:58:38 +02:00
Craig Raw
7a99c4a11a add tapsigner signing support and refactor card api 2023-01-27 10:39:29 +02:00
Craig Raw
6c13504644 implement card initialization functionality 2023-01-26 15:47:33 +02:00
Craig Raw
3ddf4ed4b2 add functionality for tapsigner backup and pin change 2023-01-26 13:00:25 +02:00
Craig Raw
6b59ff60ad initialize and import tapsigner as keystore 2023-01-25 14:19:22 +02:00
Craig Raw
7c64d689fd cormorant: threading and scan date initialization improvements 2023-01-20 12:56:25 +02:00
Craig Raw
4ad9cdedb6 add merge function for wallet transaction entry edge case 2023-01-19 14:12:35 +02:00
Craig Raw
276cb8aecb cormorant: support transaction.get without txindex, use step function to add bip47 addresses 2023-01-19 13:52:47 +02:00
Craig Raw
e7ed82699c add jade multisig export to wallet export dialog 2023-01-18 13:25:39 +02:00
Craig Raw
68cd3673af upgrade to hwi 2.2.0, add support for entering empty passphrases to trezor one 2023-01-18 11:13:13 +02:00
Craig Raw
5f96570c07 request treetable focus after editing a label cell 2023-01-16 14:46:24 +02:00
Craig Raw
5147ee8aee prefer loading transaction inputs from existing wallet transactions 2023-01-16 14:13:15 +02:00
Craig Raw
3cc2981b72 followup 2023-01-11 14:14:31 +02:00
Craig Raw
8038298485 show lifehash for master fingerprint in settings and passphrase dialog 2023-01-11 14:01:41 +02:00
Craig Raw
d1a1bd5751 fix persistence of renaming and deleting newly created wallet accounts 2023-01-09 09:57:35 +02:00
Craig Raw
63b7aef91e upgrade gradle to 7.6 2023-01-07 08:23:44 +02:00
Craig Raw
8a51a47156 upgrade gradle to 7.6 2023-01-06 10:49:26 +02:00
Craig Raw
827efe071e upgrade junit 2023-01-05 12:17:49 +02:00
Craig Raw
56784b684a allow expired certificates for electrum servers so long as they have been previously used or explicitly approved 2022-12-16 12:06:05 +02:00
Craig Raw
1fa52f043c add fee column to transactions csv for outgoing (spending) transactions 2022-12-15 17:04:01 +02:00
Craig Raw
ce44cfe877 export 8 decimal places when exporting a csv in btc units 2022-12-15 16:37:33 +02:00
Craig Raw
8ba0a9f360 cormorant: increase descriptor wallet gap limit for postmix receive chain 2022-12-15 16:00:56 +02:00
Craig Raw
41dabac75b cormorant: syncing and pruning improvements 2022-12-15 14:03:56 +02:00
Craig Raw
064708f088 avoid copying wallet history unnecessarily on wallet load 2022-12-15 08:29:25 +02:00
Craig Raw
66dc394215 cormorant: send first scan event immediately 2022-12-14 16:29:16 +02:00
Craig Raw
5ca60699ef cormorant: improve scanning behaviour 2022-12-14 14:55:00 +02:00
Craig Raw
61d9ad1875 cormorant: rbf handling and related fixes 2022-12-12 12:55:19 +02:00
Craig Raw
af6bbebac4 cormorant: add batching support 2022-12-12 11:33:51 +02:00
Craig Raw
6f4fc4f2ca avoid triggering all history changed event on unconfident script hash status calculations 2022-12-12 10:49:29 +02:00
Craig Raw
00f5001385 cormorant: fix scan date of nested wallet import 2022-12-12 10:09:22 +02:00
Craig Raw
3f3cdca94f refactor out unnecessary parameter 2022-12-12 09:14:17 +02:00
Craig Raw
08cf01a5c6 add cormorant server to support bitcoin core descriptor wallets 2022-12-08 08:42:40 +02:00
Craig Raw
df7f40dbc9 followup 2022-12-07 11:00:16 +02:00
Craig Raw
12c1725260 fix edge case when loading wallets with matching tx inputs and outputs 2022-12-06 11:22:35 +02:00
Craig Raw
aa8380eb03 add https protocol for bitcoin core connections over tls 2022-12-05 11:57:25 +02:00
Craig Raw
0e26f8fce1 add note on disabling derivation path validation to keystore help tooltip 2022-12-03 07:41:56 +02:00
Craig Raw
8de14dcbce improve handling of certain electrum server errors 2022-12-02 17:16:34 +02:00
Craig Raw
6871810c7c improve display of json rpc error exceptions 2022-12-01 09:39:47 +02:00
Craig Raw
6ac294920e improve encapsulation and binding lifecycle of cell confirmation listeners 2022-11-30 11:12:01 +02:00
Craig Raw
4b32eb397e add seedtool svg icons 2022-11-30 08:31:06 +02:00
Craig Raw
b25297e8b9 fix sparrow export file extension to be always mv.db 2022-11-28 12:59:30 +02:00
Craig Raw
ff90a2c3e6 add block height to terminal connected label 2022-11-28 09:43:08 +02:00
Craig Raw
3cbe8d1537 set initial focus to done button on terminal server test dialog 2022-11-21 16:21:30 +02:00
Craig Raw
9293b622a3 allow message sign/verify from nested segwit wallets 2022-11-21 16:09:59 +02:00
Craig Raw
6337e1cf7d update dependencies for guava, simple-json-rpc and dependants 2022-11-21 12:08:27 +02:00
craigraw
3ff3fb29b0
Merge pull request #753 from lukechilds/patch-1
Update bitcoin.lukechilds.co to bitcoin.lu.ke
2022-11-21 09:09:37 +02:00
craigraw
149d297193
Merge pull request #752 from BitcoinQnA/master
Updated Passport Single/Multisig Import and Export Instructions
2022-11-21 09:05:34 +02:00
Craig Raw
47f7b8870c fix cancel of create wallet in terminal 2022-11-21 08:51:09 +02:00
Luke Childs
7ce7d37da7
Update bitcoin.lukechilds.co to bitcoin.lu.ke 2022-11-19 03:30:36 +07:00
BitcoinQnA
b422c754d6
Update Passport Single-sig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:52:58 +00:00
BitcoinQnA
be6e9019dc
Update Passport Multisig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:49:26 +00:00
765 changed files with 29426 additions and 13591 deletions

View file

@ -2,21 +2,24 @@ name: Package
on: workflow_dispatch on: workflow_dispatch
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [windows-latest, ubuntu-latest, macos-latest] os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-13, macos-14]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v5
with: with:
submodules: true submodules: true
- name: Set up JDK 18.0.1 - name: Set up JDK 22.0.2
uses: actions/setup-java@v3 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '18.0.1' java-version: '22.0.2'
- name: Show Build Versions - name: Show Build Versions
run: ./gradlew -v run: ./gradlew -v
- name: Build with Gradle - name: Build with Gradle
@ -27,10 +30,13 @@ jobs:
- name: Package tar distribution - name: Package tar distribution
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution run: ./gradlew packageTarDistribution
- name: Upload Artifacts - name: Repackage deb distribution
uses: actions/upload-artifact@v3 if: ${{ runner.os == 'Linux' }}
run: ./repackage.sh
- name: Upload Artifact
uses: actions/upload-artifact@v4
with: with:
name: Sparrow Build - ${{ runner.os }} name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }}
path: | path: |
build/jpackage/* build/jpackage/*
!build/jpackage/Sparrow/ !build/jpackage/Sparrow/
@ -40,14 +46,14 @@ jobs:
- name: Package headless tar distribution - name: Package headless tar distribution
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Rename Headless Artifacts - name: Repackage headless deb distribution
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
run: for f in build/jpackage/sparrow*; do mv -v "$f" "${f/sparrow/sparrow-server}"; done; run: ./repackage.sh
- name: Upload Headless Artifact - name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Sparrow Build - ${{ runner.os }} Headless name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }} Headless
path: | path: |
build/jpackage/* build/jpackage/*
!build/jpackage/Sparrow/ !build/jpackage/Sparrow/

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[submodule "drongo"] [submodule "drongo"]
path = drongo path = drongo
url = ../../sparrowwallet/drongo.git url = ../../sparrowwallet/drongo.git
[submodule "lark"]
path = lark
url = ../../sparrowwallet/lark.git

View file

@ -16,8 +16,8 @@ or for those without SSH credentials:
`git clone --recursive https://github.com/sparrowwallet/sparrow.git` `git clone --recursive https://github.com/sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 17 or higher to be installed. In order to build, Sparrow requires Java 22 or higher to be installed.
The release binaries are built with [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10). The release binaries are built with [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems: Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
@ -44,7 +44,7 @@ If you prefer to run Sparrow directly from source, it can be launched from withi
`./sparrow` `./sparrow`
Java 17 or higher must be installed. Java 22 or higher must be installed.
## Configuration ## Configuration
@ -64,10 +64,12 @@ Usage: sparrow [options]
Possible Values: [ERROR, WARN, INFO, DEBUG, TRACE] Possible Values: [ERROR, WARN, INFO, DEBUG, TRACE]
--network, -n --network, -n
Network to use Network to use
Possible Values: [mainnet, testnet, regtest, signet] Possible Values: [mainnet, testnet, regtest, signet, testnet4]
``` ```
As a fallback, the network (mainnet, testnet, regtest or signet) can also be set using an environment variable `SPARROW_NETWORK`. For example: Note that testnet currently refers to testnet3.
As a fallback, the network (mainnet, testnet, testnet4, regtest or signet) can also be set using an environment variable `SPARROW_NETWORK`. For example:
`export SPARROW_NETWORK=testnet` `export SPARROW_NETWORK=testnet`
@ -83,7 +85,7 @@ When not explicitly configured using the command line argument above, Sparrow st
| Linux | ~/.sparrow | | Linux | ~/.sparrow |
| Windows | %APPDATA%/Sparrow | | Windows | %APPDATA%/Sparrow |
Testnet, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks. Testnet3, testnet4, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
## Reporting Issues ## Reporting Issues

View file

@ -1,46 +1,38 @@
import java.awt.GraphicsEnvironment
plugins { plugins {
id 'application' id 'application'
id 'org-openjfx-javafxplugin' id 'org-openjfx-javafxplugin'
id 'extra-java-module-info' id 'org.beryx.jlink' version '3.1.3'
id 'org.beryx.jlink' version '2.25.0' id 'org.gradlex.extra-java-module-info' version '1.13'
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.16.3'
} }
def sparrowVersion = '1.7.1'
def os = org.gradle.internal.os.OperatingSystem.current() def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName() def osName = os.getFamilyName()
if(os.macOsX) { if(os.macOsX) {
osName = "osx" osName = "osx"
} }
def targetName = ""
def osArch = "x64" def osArch = "x64"
def releaseArch = "x86_64" def releaseArch = "x86_64"
if(System.getProperty("os.arch") == "aarch64") { if(System.getProperty("os.arch") == "aarch64") {
osArch = "aarch64" osArch = "aarch64"
releaseArch = "aarch64" releaseArch = "aarch64"
targetName = "-" + osArch
} }
def headless = "true".equals(System.getProperty("java.awt.headless")) def headless = "true".equals(System.getProperty("java.awt.headless"))
group "com.sparrowwallet" group = 'com.sparrowwallet'
version "${sparrowVersion}" version = '2.3.1'
repositories { repositories {
mavenCentral() mavenCentral()
maven { url 'https://oss.sonatype.org/content/groups/public' } maven { url = uri('https://code.sparrowwallet.com/api/packages/sparrowwallet/maven') }
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
maven { url 'https://jitpack.io' }
maven { url 'https://maven.ecs.soton.ac.uk/content/groups/maven.openimaj.org/' }
} }
tasks.withType(AbstractArchiveTask) { tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false useFileSystemPermissions()
reproducibleFileOrder = true
} }
javafx { javafx {
version = "18" version = headless ? "18" : "23.0.2"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ] modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
} }
@ -50,45 +42,46 @@ java {
dependencies { dependencies {
//Any changes to the dependencies must be reflected in the module definitions below! //Any changes to the dependencies must be reflected in the module definitions below!
implementation(project(':drongo')) { implementation(project(':drongo'))
exclude group: 'org.hamcrest' implementation(project(':lark'))
exclude group: 'junit' implementation('com.google.guava:guava:33.5.0-jre')
} implementation('com.google.code.gson:gson:2.9.1')
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:2.1.214') implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3') implementation('com.zaxxer:HikariCP:4.0.3') {
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j' exclude group: 'org.slf4j'
} }
implementation('org.jdbi:jdbi3-sqlobject:3.20.0') implementation('org.jdbi:jdbi3-core:3.49.5') {
implementation('org.flywaydb:flyway-core:7.10.7-SNAPSHOT') exclude group: 'org.slf4j'
implementation('org.fxmisc.richtext:richtextfx:0.10.4') }
implementation('org.jdbi:jdbi3-sqlobject:3.49.5') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:9.22.3')
implementation('org.fxmisc.richtext:richtextfx:0.11.6')
implementation('no.tornado:tornadofx-controls:1.0.4') implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') { implementation('com.google.zxing:javase:3.4.0') {
exclude group: 'com.beust', module: 'jcommander' exclude group: 'com.beust', module: 'jcommander'
} }
implementation('com.beust:jcommander:1.81') implementation('org.jcommander:jcommander:2.0')
implementation('com.github.arteam:simple-json-rpc-core:1.0') implementation('com.github.arteam:simple-json-rpc-core:1.3')
implementation('com.github.arteam:simple-json-rpc-client:1.0') { implementation('com.github.arteam:simple-json-rpc-client:1.3') {
exclude group: 'com.github.arteam', module: 'simple-json-rpc-core' exclude group: 'com.github.arteam', module: 'simple-json-rpc-core'
} }
implementation('com.github.arteam:simple-json-rpc-server:1.0') { implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j' exclude group: 'org.slf4j'
} }
implementation('com.sparrowwallet:hummingbird:1.6.4') implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
implementation('com.sparrowwallet:hummingbird:1.7.4')
implementation('co.nstant.in:cbor:0.9') implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") { implementation('org.openpnp:openpnp-capture-java:0.0.30-1')
exclude group: 'com.google.android.tools', module: 'dx' implementation("io.matthewnelson.kmp-tor:runtime:2.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
} }
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") { implementation('de.jangassen:nsmenufx:3.1.0') {
exclude group: 'com.nativelibs4java', module: 'bridj' exclude group: 'net.java.dev.jna', module: 'jna'
} }
implementation("com.sparrowwallet:netlayer-jpms-${osName}${targetName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.1.0' ) { implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base' exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics' exclude group: 'org.openjfx', module: 'javafx-graphics'
@ -100,24 +93,27 @@ dependencies {
} }
implementation('dev.bwt:bwt-jni:0.1.8') implementation('dev.bwt:bwt-jni:0.1.8')
implementation('net.sourceforge.javacsv:javacsv:2.0') implementation('net.sourceforge.javacsv:javacsv:2.0')
implementation('org.slf4j:jul-to-slf4j:1.7.30') { implementation ('org.slf4j:slf4j-api:2.0.12')
implementation('org.slf4j:jul-to-slf4j:2.0.12') {
exclude group: 'org.slf4j' exclude group: 'org.slf4j'
} }
implementation('com.sparrowwallet.nightjar:nightjar:0.2.33') implementation('com.sparrowwallet.bokmakierie:bokmakierie:1.0')
implementation('com.sparrowwallet:tern:1.0.6')
implementation('io.reactivex.rxjava2:rxjava:2.2.15') implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2') implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7') implementation('org.apache.commons:commons-lang3:3.19.0')
implementation('org.apache.commons:commons-compress:1.27.1')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0') implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.27') implementation('com.github.librepdf:openpdf:1.3.30')
implementation('com.googlecode.lanterna:lanterna:3.1.1') implementation('com.googlecode.lanterna:lanterna:3.1.3')
implementation('net.coobird:thumbnailator:0.4.18') implementation('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2') implementation('com.github.hervegirod:fxsvgimage:1.1')
testImplementation('junit:junit:4.12') implementation('com.sparrowwallet:toucan:0.9.0')
} implementation('com.jcraft:jzlib:1.1.3')
implementation('io.github.doblon8:jzbar:0.2.1')
application { testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
mainModule = 'com.sparrowwallet.sparrow' testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet' testRuntimeOnly('org.junit.platform:junit-platform-launcher')
} }
compileJava { compileJava {
@ -138,11 +134,21 @@ processResources {
} }
test { test {
jvmArgs '--add-opens=java.base/java.io=com.google.gson' useJUnitPlatform()
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--add-opens=java.base/java.io=com.google.gson", "--add-reads=org.flywaydb.core=java.desktop"]
} }
run { application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError", applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
"--enable-native-access=com.sparrowwallet.drongo",
"--enable-native-access=com.sun.jna",
"--enable-native-access=javafx.graphics",
"--enable-native-access=com.fazecast.jSerialComm",
"--enable-native-access=org.usb4java",
"--enable-native-access=io.github.doblon8.jzbar",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", "--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
@ -152,19 +158,17 @@ run {
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow", "--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow", "--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml", "--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson"] "--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
"--add-reads=org.flywaydb.core=java.desktop"]
if(os.macOsX) { if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png", applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow"]
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
} }
if(headless) { if(headless) {
applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"] applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
@ -181,17 +185,20 @@ jlink {
requires 'jdk.crypto.cryptoki' requires 'jdk.crypto.cryptoki'
requires 'java.management' requires 'java.management'
requires 'io.leangen.geantyref' requires 'io.leangen.geantyref'
uses 'org.flywaydb.core.extensibility.FlywayExtension'
uses 'org.flywaydb.core.internal.database.DatabaseType'
uses 'org.eclipse.jetty.http.HttpFieldPreEncoder' uses 'org.eclipse.jetty.http.HttpFieldPreEncoder'
uses 'org.eclipse.jetty.websocket.api.extensions.Extension'
uses 'org.eclipse.jetty.websocket.common.RemoteEndpointFactory'
} }
options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*'] options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', 'zip-6', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
launcher { launcher {
name = 'sparrow' name = 'sparrow'
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls", jvmArgs = ["--enable-native-access=com.sparrowwallet.drongo",
"--enable-native-access=com.sun.jna",
"--enable-native-access=javafx.graphics",
"--enable-native-access=com.sparrowwallet.merged.module",
"--enable-native-access=com.fazecast.jSerialComm",
"--enable-native-access=org.usb4java",
"--enable-native-access=io.github.doblon8.jzbar",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls", "--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls", "--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
@ -200,23 +207,27 @@ jlink {
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow", "--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow", "--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml", "--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson", "--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop", "--add-reads=com.sparrowwallet.merged.module=java.desktop",
"--add-reads=com.sparrowwallet.merged.module=java.sql", "--add-reads=com.sparrowwallet.merged.module=java.sql",
"--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow", "--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=logback.classic", "--add-reads=com.sparrowwallet.merged.module=ch.qos.logback.classic",
"--add-reads=com.sparrowwallet.merged.module=org.slf4j",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.databind", "--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.databind",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation", "--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core", "--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor"] "--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.pg",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.provider",
"--add-reads=com.sparrowwallet.merged.module=kotlin.stdlib",
"--add-reads=com.sparrowwallet.merged.module=org.reactfx.reactfx",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
"--add-reads=org.flywaydb.core=java.desktop"]
if(os.windows) { if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"] jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
@ -232,22 +243,24 @@ jlink {
jpackage { jpackage {
imageName = "Sparrow" imageName = "Sparrow"
installerName = "Sparrow" installerName = "Sparrow"
appVersion = "${sparrowVersion}" appVersion = "${version}"
skipInstaller = os.macOsX || properties.skipInstallers skipInstaller = os.macOsX || properties.skipInstallers
imageOptions = [] imageOptions = []
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.properties', '--license-file', 'LICENSE'] installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.properties', '--license-file', 'LICENSE']
if(os.windows) { if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/'] installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico'] imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
installerType = "exe" installerType = "msi"
} }
if(os.linux) { if(os.linux) {
if(headless) { if(headless) {
installerName = "sparrowserver"
installerOptions = ['--license-file', 'LICENSE'] installerOptions = ['--license-file', 'LICENSE']
} else { } else {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow'] installerName = "sparrowwallet"
installerOptions += ['--linux-shortcut', '--linux-menu-group', 'Sparrow']
} }
installerOptions += ['--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com'] installerOptions += ['--resource-dir', layout.buildDirectory.dir('deploy/package').get().asFile.toString(), '--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/'] imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
} }
if(os.macOsX) { if(os.macOsX) {
@ -256,24 +269,82 @@ jlink {
installerType = "dmg" installerType = "dmg"
} }
} }
if(os.linux) {
jpackageImage {
dependsOn('prepareModulesDir', 'copyUdevRules')
}
}
} }
task removeGroupWritePermission(type: Exec) { if(os.linux) {
tasks.jlink.finalizedBy('addUserWritePermission', 'copyUdevRules')
tasks.jpackageImage.finalizedBy('prepareResourceDir')
} else {
tasks.jlink.finalizedBy('addUserWritePermission')
}
tasks.register('addUserWritePermission', Exec) {
if(os.windows) {
def usersGroup = '*S-1-5-32-545' // Windows "Users" group SID (language-independent)
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', "${usersGroup}:(OI)(CI)F", '/T'
} else {
commandLine 'chmod', '-R', 'u+w', "$buildDir/image/legal"
}
}
tasks.register('copyUdevRules', Copy) {
from('lark/src/main/resources/udev')
into(layout.buildDirectory.dir('image/conf/udev'))
include('*')
}
tasks.register('prepareResourceDir', Copy) {
from("src/main/deploy/package/linux${headless ? '-headless' : ''}")
into(layout.buildDirectory.dir('deploy/package'))
include('*')
eachFile { file ->
if(file.name.equals('control') || file.name.endsWith('.spec')) {
filter { line ->
if(line.contains('${size}')) {
line = line.replace('${size}', getDirectorySize(layout.buildDirectory.dir('jpackage/Sparrow').get().asFile))
}
return line.replace('${version}', "${version}").replace('${arch}', osArch == 'aarch64' ? 'arm64' : 'amd64')
}
}
}
}
static def getDirectorySize(File directory) {
long size = 0
if(directory.isFile()) {
size = directory.length()
} else if(directory.isDirectory()) {
directory.eachFileRecurse { file ->
if(file.isFile()) {
size += file.length()
}
}
}
return Long.toString(size/1024 as long)
}
tasks.register('removeGroupWritePermission', Exec) {
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow" commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
} }
task packageZipDistribution(type: Zip) { tasks.register('packageZipDistribution', Zip) {
archiveFileName = "Sparrow-${sparrowVersion}.zip" archiveFileName = "Sparrow-${version}.zip"
destinationDirectory = file("$buildDir/jpackage") destinationDirectory = file("$buildDir/jpackage")
preserveFileTimestamps = os.macOsX
from("$buildDir/jpackage/") { from("$buildDir/jpackage/") {
include "Sparrow/**" include "Sparrow/**"
include "Sparrow.app/**" include "Sparrow.app/**"
} }
} }
task packageTarDistribution(type: Tar) { tasks.register('packageTarDistribution', Tar) {
dependsOn removeGroupWritePermission dependsOn removeGroupWritePermission
archiveFileName = "sparrow-${sparrowVersion}-${releaseArch}.tar.gz" archiveFileName = "sparrow${headless ? 'server': 'wallet'}-${version}-${releaseArch}.tar.gz"
destinationDirectory = file("$buildDir/jpackage") destinationDirectory = file("$buildDir/jpackage")
compression = Compression.GZIP compression = Compression.GZIP
from("$buildDir/jpackage/") { from("$buildDir/jpackage/") {
@ -282,60 +353,11 @@ task packageTarDistribution(type: Tar) {
} }
extraJavaModuleInfo { extraJavaModuleInfo {
module('jackson-core-2.10.1.jar', 'com.fasterxml.jackson.core', '2.10.1') { module('no.tornado:tornadofx-controls', 'tornadofx.controls') {
exports('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.core.async')
exports('com.fasterxml.jackson.core.base')
exports('com.fasterxml.jackson.core.exc')
exports('com.fasterxml.jackson.core.filter')
exports('com.fasterxml.jackson.core.format')
exports('com.fasterxml.jackson.core.io')
exports('com.fasterxml.jackson.core.json')
exports('com.fasterxml.jackson.core.json.async')
exports('com.fasterxml.jackson.core.sym')
exports('com.fasterxml.jackson.core.type')
exports('com.fasterxml.jackson.core.util')
uses('com.fasterxml.jackson.core.ObjectCodec')
}
module('jackson-annotations-2.10.1.jar', 'com.fasterxml.jackson.annotation', '2.10.1') {
requires('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.annotation')
}
module('jackson-databind-2.10.1.jar', 'com.fasterxml.jackson.databind', '2.10.1') {
requires('java.desktop')
requires('java.logging')
requires('com.fasterxml.jackson.annotation')
requires('com.fasterxml.jackson.core')
requires('java.sql')
requires('java.xml')
exports('com.fasterxml.jackson.databind')
exports('com.fasterxml.jackson.databind.annotation')
exports('com.fasterxml.jackson.databind.cfg')
exports('com.fasterxml.jackson.databind.deser')
exports('com.fasterxml.jackson.databind.deser.impl')
exports('com.fasterxml.jackson.databind.deser.std')
exports('com.fasterxml.jackson.databind.exc')
exports('com.fasterxml.jackson.databind.ext')
exports('com.fasterxml.jackson.databind.introspect')
exports('com.fasterxml.jackson.databind.json')
exports('com.fasterxml.jackson.databind.jsonFormatVisitors')
exports('com.fasterxml.jackson.databind.jsonschema')
exports('com.fasterxml.jackson.databind.jsontype')
exports('com.fasterxml.jackson.databind.jsontype.impl')
exports('com.fasterxml.jackson.databind.module')
exports('com.fasterxml.jackson.databind.node')
exports('com.fasterxml.jackson.databind.ser')
exports('com.fasterxml.jackson.databind.ser.impl')
exports('com.fasterxml.jackson.databind.ser.std')
exports('com.fasterxml.jackson.databind.type')
exports('com.fasterxml.jackson.databind.util')
uses('com.fasterxml.jackson.databind.Module')
}
module('tornadofx-controls-1.0.4.jar', 'tornadofx.controls', '1.0.4') {
exports('tornadofx.control') exports('tornadofx.control')
requires('javafx.controls') requires('javafx.controls')
} }
module('simple-json-rpc-core-1.0.jar', 'simple.json.rpc.core', '1.0') { module('com.github.arteam:simple-json-rpc-core', 'simple.json.rpc.core') {
exports('com.github.arteam.simplejsonrpc.core.annotation') exports('com.github.arteam.simplejsonrpc.core.annotation')
exports('com.github.arteam.simplejsonrpc.core.domain') exports('com.github.arteam.simplejsonrpc.core.domain')
requires('com.fasterxml.jackson.core') requires('com.fasterxml.jackson.core')
@ -343,7 +365,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind') requires('com.fasterxml.jackson.databind')
requires('org.jetbrains.annotations') requires('org.jetbrains.annotations')
} }
module('simple-json-rpc-client-1.0.jar', 'simple.json.rpc.client', '1.0') { module('com.github.arteam:simple-json-rpc-client', 'simple.json.rpc.client') {
exports('com.github.arteam.simplejsonrpc.client') exports('com.github.arteam.simplejsonrpc.client')
exports('com.github.arteam.simplejsonrpc.client.builder') exports('com.github.arteam.simplejsonrpc.client.builder')
exports('com.github.arteam.simplejsonrpc.client.exception') exports('com.github.arteam.simplejsonrpc.client.exception')
@ -351,90 +373,26 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind') requires('com.fasterxml.jackson.databind')
requires('simple.json.rpc.core') requires('simple.json.rpc.core')
} }
module('simple-json-rpc-server-1.0.jar', 'simple.json.rpc.server', '1.0') { module('com.github.arteam:simple-json-rpc-server', 'simple.json.rpc.server') {
exports('com.github.arteam.simplejsonrpc.server') exports('com.github.arteam.simplejsonrpc.server')
requires('simple.json.rpc.core') requires('simple.json.rpc.core')
requires('com.google.common') requires('com.google.common')
requires('org.slf4j') requires('org.slf4j')
requires('com.fasterxml.jackson.databind') requires('com.fasterxml.jackson.databind')
} }
module("bridj${targetName}-0.7-20140918-3.jar", 'com.nativelibs4java.bridj', '0.7-20140918-3') { module('org.openpnp:openpnp-capture-java', 'openpnp.capture.java') {
exports('org.bridj') exports('org.openpnp.capture')
exports('org.bridj.cpp') exports('org.openpnp.capture.library')
requires('java.logging')
}
module("webcam-capture${targetName}-0.3.13-SNAPSHOT.jar", 'com.github.sarxos.webcam.capture', '0.3.13-SNAPSHOT') {
exports('com.github.sarxos.webcam')
exports('com.github.sarxos.webcam.ds.buildin')
exports('com.github.sarxos.webcam.ds.buildin.natives')
requires('java.desktop') requires('java.desktop')
requires('com.nativelibs4java.bridj') requires('com.sun.jna')
requires('org.slf4j')
} }
module('centerdevice-nsmenufx-2.1.7.jar', 'centerdevice.nsmenufx', '2.1.7') { module('net.sourceforge.javacsv:javacsv', 'net.sourceforge.javacsv') {
exports('de.codecentric.centerdevice')
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
}
module('javacsv-2.0.jar', 'net.sourceforge.javacsv', '2.0') {
exports('com.csvreader') exports('com.csvreader')
} }
module('jul-to-slf4j-1.7.30.jar', 'org.slf4j.jul.to.slf4j', '1.7.30') { module('com.google.guava:listenablefuture|empty-to-avoid-conflict-with-guava', 'com.google.guava.listenablefuture')
exports('org.slf4j.bridge') module('com.google.code.findbugs:jsr305', 'com.google.code.findbugs.jsr305')
requires('java.logging') module('j2objc-annotations-2.8.jar', 'com.google.j2objc.j2objc.annotations', '2.8')
requires('org.slf4j') module('org.fxmisc.richtext:richtextfx', 'org.fxmisc.richtext') {
}
module('jeromq-0.5.0.jar', 'jeromq', '0.5.0') {
exports('org.zeromq')
}
module('json-simple-1.1.1.jar', 'json.simple', '1.1.1') {
exports('org.json.simple')
}
module('logback-classic-1.2.8.jar', 'logback.classic', '1.2.8') {
exports('ch.qos.logback.classic')
requires('org.slf4j')
requires('logback.core')
requires('java.xml')
requires('java.logging')
}
module('kotlin-logging-1.5.4.jar', 'io.github.microutils.kotlin.logging', '1.5.4') {
exports('mu')
requires('kotlin.stdlib')
requires('org.slf4j')
}
module('failureaccess-1.0.1.jar', 'failureaccess', '1.0.1') {
exports('com.google.common.util.concurrent.internal')
}
module('listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar', 'com.google.guava.listenablefuture', '9999.0-empty-to-avoid-conflict-with-guava')
module('guava-28.2-jre.jar', 'com.google.common', '28.2-jre') {
exports('com.google.common.eventbus')
exports('com.google.common.net')
exports('com.google.common.base')
exports('com.google.common.collect')
exports('com.google.common.io')
exports('com.google.common.primitives')
exports('com.google.common.math')
requires('failureaccess')
requires('java.logging')
}
module('jsr305-3.0.2.jar', 'com.google.code.findbugs.jsr305', '3.0.2')
module('j2objc-annotations-1.3.jar', 'com.google.j2objc.j2objc.annotations', '1.3')
module('jdbi3-core-3.20.0.jar', 'org.jdbi.v3.core', '3.20.0') {
exports('org.jdbi.v3.core')
exports('org.jdbi.v3.core.mapper')
exports('org.jdbi.v3.core.statement')
exports('org.jdbi.v3.core.result')
exports('org.jdbi.v3.core.h2')
exports('org.jdbi.v3.core.spi')
requires('io.leangen.geantyref')
requires('java.sql')
requires('org.slf4j')
}
module('geantyref-1.3.11.jar', 'io.leangen.geantyref', '1.3.11') {
exports('io.leangen.geantyref')
}
module('richtextfx-0.10.4.jar', 'org.fxmisc.richtext', '0.10.4') {
exports('org.fxmisc.richtext') exports('org.fxmisc.richtext')
exports('org.fxmisc.richtext.event') exports('org.fxmisc.richtext.event')
exports('org.fxmisc.richtext.model') exports('org.fxmisc.richtext.model')
@ -443,23 +401,23 @@ extraJavaModuleInfo {
requires('javafx.graphics') requires('javafx.graphics')
requires('org.fxmisc.flowless') requires('org.fxmisc.flowless')
requires('org.reactfx.reactfx') requires('org.reactfx.reactfx')
requires('org.fxmisc.undo.undofx') requires('org.fxmisc.undo')
requires('org.fxmisc.wellbehaved') requires('org.fxmisc.wellbehaved')
} }
module('undofx-2.1.0.jar', 'org.fxmisc.undo.undofx', '2.1.0') { module('org.fxmisc.undo:undofx', 'org.fxmisc.undo') {
requires('javafx.base') requires('javafx.base')
requires('javafx.controls') requires('javafx.controls')
requires('javafx.graphics') requires('javafx.graphics')
requires('org.reactfx.reactfx') requires('org.reactfx.reactfx')
} }
module('flowless-0.6.1.jar', 'org.fxmisc.flowless', '0.6.1') { module('org.fxmisc.flowless:flowless', 'org.fxmisc.flowless') {
exports('org.fxmisc.flowless') exports('org.fxmisc.flowless')
requires('javafx.base') requires('javafx.base')
requires('javafx.controls') requires('javafx.controls')
requires('javafx.graphics') requires('javafx.graphics')
requires('org.reactfx.reactfx') requires('org.reactfx.reactfx')
} }
module('reactfx-2.0-M5.jar', 'org.reactfx.reactfx', '2.0-M5') { module('org.reactfx:reactfx', 'org.reactfx.reactfx') {
exports('org.reactfx') exports('org.reactfx')
exports('org.reactfx.value') exports('org.reactfx.value')
exports('org.reactfx.collection') exports('org.reactfx.collection')
@ -468,162 +426,57 @@ extraJavaModuleInfo {
requires('javafx.graphics') requires('javafx.graphics')
requires('javafx.controls') requires('javafx.controls')
} }
module('rxjavafx-2.2.2.jar', 'io.reactivex.rxjava2fx', '2.2.2') { module('io.reactivex.rxjava2:rxjavafx', 'io.reactivex.rxjava2fx') {
exports('io.reactivex.rxjavafx.schedulers') exports('io.reactivex.rxjavafx.schedulers')
requires('io.reactivex.rxjava2') requires('io.reactivex.rxjava2')
requires('javafx.graphics') requires('javafx.graphics')
} }
module('wellbehavedfx-0.3.3.jar', 'org.fxmisc.wellbehaved', '0.3.3') { module('org.flywaydb:flyway-core', 'org.flywaydb.core') {
exports('org.flywaydb.core')
exports('org.flywaydb.core.api')
exports('org.flywaydb.core.api.exception')
exports('org.flywaydb.core.api.configuration')
uses('org.flywaydb.core.extensibility.Plugin')
requires('java.sql')
}
module('org.fxmisc.wellbehaved:wellbehavedfx', 'org.fxmisc.wellbehaved') {
requires('javafx.base') requires('javafx.base')
requires('javafx.graphics') requires('javafx.graphics')
} }
module('jai-imageio-core-1.4.0.jar', 'com.github.jai.imageio.jai.imageio.core', '1.4.0') module('com.github.jai-imageio:jai-imageio-core', 'com.github.jai.imageio.jai.imageio.core') {
module('kotlin-stdlib-jdk8-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk8', '1.5.20') requires('java.desktop')
module('kotlin-stdlib-jdk7-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk7', '1.5.20')
module('kotlin-stdlib-1.5.20.jar', 'kotlin.stdlib', '1.5.20') {
exports('kotlin')
exports('kotlin.jvm')
exports('kotlin.collections')
} }
module('hummingbird-1.6.3.jar', 'com.sparrowwallet.hummingbird', '1.6.3') { module('co.nstant.in:cbor', 'co.nstant.in.cbor') {
exports('com.sparrowwallet.hummingbird')
exports('com.sparrowwallet.hummingbird.registry')
requires('co.nstant.in.cbor')
}
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor') exports('co.nstant.in.cbor')
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
} }
module('nightjar-0.2.33.jar', 'com.sparrowwallet.nightjar', '0.2.33') { module('net.sourceforge.streamsupport:streamsupport', 'net.sourceforge.streamsupport') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
requires('org.bouncycastle.provider')
requires('com.fasterxml.jackson.databind')
requires('com.fasterxml.jackson.annotation')
requires('com.fasterxml.jackson.core')
requires('logback.classic')
requires('org.json')
requires('io.reactivex.rxjava2')
exports('com.samourai.http.client')
exports('com.samourai.tor.client')
exports('com.samourai.wallet.api.backend')
exports('com.samourai.wallet.api.backend.beans')
exports('com.samourai.wallet.client.indexHandler')
exports('com.samourai.wallet.hd')
exports('com.samourai.wallet.util')
exports('com.samourai.wallet.bip47.rpc')
exports('com.samourai.wallet.bip47.rpc.java')
exports('com.samourai.wallet.cahoots')
exports('com.samourai.wallet.cahoots.psbt')
exports('com.samourai.wallet.cahoots.stonewallx2')
exports('com.samourai.soroban.cahoots')
exports('com.samourai.soroban.client')
exports('com.samourai.soroban.client.cahoots')
exports('com.samourai.soroban.client.meeting')
exports('com.samourai.soroban.client.rpc')
exports('com.samourai.wallet.send')
exports('com.samourai.whirlpool.client.event')
exports('com.samourai.whirlpool.client.wallet')
exports('com.samourai.whirlpool.client.wallet.beans')
exports('com.samourai.whirlpool.client.wallet.data.dataSource')
exports('com.samourai.whirlpool.client.wallet.data.dataPersister')
exports('com.samourai.whirlpool.client.whirlpool')
exports('com.samourai.whirlpool.client.whirlpool.beans')
exports('com.samourai.whirlpool.client.wallet.data.pool')
exports('com.samourai.whirlpool.client.wallet.data.utxo')
exports('com.samourai.whirlpool.client.wallet.data.utxoConfig')
exports('com.samourai.whirlpool.client.wallet.data.supplier')
exports('com.samourai.whirlpool.client.mix.handler')
exports('com.samourai.whirlpool.client.mix.listener')
exports('com.samourai.whirlpool.protocol.beans')
exports('com.samourai.whirlpool.protocol.rest')
exports('com.samourai.whirlpool.client.tx0')
exports('com.samourai.wallet.segwit.bech32')
exports('com.samourai.whirlpool.client.wallet.data.chain')
exports('com.samourai.whirlpool.client.wallet.data.wallet')
exports('com.samourai.whirlpool.client.wallet.data.minerFee')
exports('com.samourai.whirlpool.client.wallet.data.walletState')
exports('com.sparrowwallet.nightjar.http')
exports('com.sparrowwallet.nightjar.stomp')
exports('com.sparrowwallet.nightjar.tor')
}
module('throwing-supplier-1.0.3.jar', 'zeroleak.throwingsupplier', '1.0.3') {
exports('com.zeroleak.throwingsupplier')
}
module('okhttp-2.7.5.jar', 'com.squareup.okhttp', '2.7.5') {
exports('com.squareup.okhttp')
}
module('okio-1.6.0.jar', 'com.squareup.okio', '1.6.0') {
exports('okio')
}
module('java-jwt-3.8.1.jar', 'com.auth0.jwt', '3.8.1') {
exports('com.auth0.jwt')
}
module('json-20180130.jar', 'org.json', '1.0') {
exports('org.json')
}
module('scrypt-1.4.0.jar', 'com.lambdaworks.scrypt', '1.4.0') {
exports('com.lambdaworks.codec')
exports('com.lambdaworks.crypto')
}
module('streamsupport-1.7.0.jar', 'net.sourceforge.streamsupport', '1.7.0') {
requires('jdk.unsupported') requires('jdk.unsupported')
exports('java8.util') exports('java8.util')
exports('java8.util.function') exports('java8.util.function')
exports('java8.util.stream') exports('java8.util.stream')
} }
module('protobuf-java-2.6.1.jar', 'com.google.protobuf', '2.6.1') { module('net.coobird:thumbnailator', 'net.coobird.thumbnailator') {
exports('com.google.protobuf')
}
module('commons-text-1.2.jar', 'org.apache.commons.text', '1.2') {
exports('org.apache.commons.text')
}
module('jcip-annotations-1.0.jar', 'net.jcip.annotations', '1.0') {
exports('net.jcip.annotations')
}
module('thumbnailator-0.4.18.jar', 'net.coobird.thumbnailator', '0.4.18') {
exports('net.coobird.thumbnailator') exports('net.coobird.thumbnailator')
requires('java.desktop') requires('java.desktop')
} }
module('fxsvgimage-1.0b2.jar', 'com.github.hervegirod', '1.0b2') { module('org.jcommander:jcommander', 'org.jcommander') {
exports('org.girod.javafx.svgimage')
requires('javafx.graphics')
requires('java.xml')
}
module("netlayer-jpms-${osName}${targetName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
exports('org.berndpruenster.netlayer.tor')
requires('com.github.ravn.jsocks')
requires('com.github.JesusMcCloud.jtorctl')
requires('kotlin.stdlib')
requires('commons.compress')
requires('org.tukaani.xz')
requires('java.management')
requires('io.github.microutils.kotlin.logging')
}
module('jtorctl-1.5.jar', 'com.github.JesusMcCloud.jtorctl', '1.5') {
exports('net.freehaven.tor.control')
}
module('commons-compress-1.18.jar', 'commons.compress', '1.18') {
exports('org.apache.commons.compress')
requires('org.tukaani.xz')
}
module('xz-1.6.jar', 'org.tukaani.xz', '1.6') {
exports('org.tukaani.xz')
}
module('jsocks-1.0.jar', 'com.github.ravn.jsocks', '1.0') {
exports('com.runjva.sourceforge.jsocks.protocol')
requires('org.slf4j')
}
module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0')
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
requires('java.xml')
}
module('jcommander-1.81.jar', 'com.beust.jcommander', '1.81') {
exports('com.beust.jcommander') exports('com.beust.jcommander')
} }
module('junit-4.12.jar', 'junit', '4.12') { module('com.sparrowwallet:hid4java', 'org.hid4java') {
exports('org.junit') requires('com.sun.jna')
requires('org.hamcrest.core') exports('org.hid4java')
exports('org.hid4java.jna')
} }
module('hamcrest-core-1.3.jar', 'org.hamcrest.core', '1.3') module('com.sparrowwallet:usb4java', 'org.usb4java') {
exports('org.usb4java')
}
module('com.jcraft:jzlib', 'com.jcraft.jzlib') {
exports('com.jcraft.jzlib')
}
}
kmpTorResourceFilterJar {
keepTorCompilation("current","current")
} }

View file

@ -3,25 +3,18 @@ plugins {
} }
dependencies { dependencies {
implementation 'org.ow2.asm:asm:8.0.1' implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.3'
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.0'
implementation 'org.javamodularity:moduleplugin:1.8.12'
} }
repositories { repositories {
mavenCentral() mavenCentral()
maven { maven {
url "https://plugins.gradle.org/m2/" url = uri("https://plugins.gradle.org/m2/")
} }
} }
gradlePlugin { gradlePlugin {
plugins { plugins {
// here we register our plugin with an ID
register("extra-java-module-info") {
id = "extra-java-module-info"
implementationClass = "org.gradle.sample.transform.javamodules.ExtraModuleInfoPlugin"
}
register("org-openjfx-javafxplugin") { register("org-openjfx-javafxplugin") {
id = "org-openjfx-javafxplugin" id = "org-openjfx-javafxplugin"
implementationClass = "org.openjfx.gradle.JavaFXPlugin" implementationClass = "org.openjfx.gradle.JavaFXPlugin"

View file

@ -1,54 +0,0 @@
package org.gradle.sample.transform.javamodules;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.attributes.Attribute;
import org.gradle.api.plugins.JavaPlugin;
/**
* Entry point of our plugin that should be applied in the root project.
*/
public class ExtraModuleInfoPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
// register the plugin extension as 'extraJavaModuleInfo {}' configuration block
ExtraModuleInfoPluginExtension extension = project.getObjects().newInstance(ExtraModuleInfoPluginExtension.class);
project.getExtensions().add(ExtraModuleInfoPluginExtension.class, "extraJavaModuleInfo", extension);
// setup the transform for all projects in the build
project.getPlugins().withType(JavaPlugin.class).configureEach(javaPlugin -> configureTransform(project, extension));
}
private void configureTransform(Project project, ExtraModuleInfoPluginExtension extension) {
Attribute<String> artifactType = Attribute.of("artifactType", String.class);
Attribute<Boolean> javaModule = Attribute.of("javaModule", Boolean.class);
// compile and runtime classpath express that they only accept modules by requesting the javaModule=true attribute
project.getConfigurations().matching(this::isResolvingJavaPluginConfiguration).all(
c -> c.getAttributes().attribute(javaModule, true));
// all Jars have a javaModule=false attribute by default; the transform also recognizes modules and returns them without modification
project.getDependencies().getArtifactTypes().getByName("jar").getAttributes().attribute(javaModule, false);
// register the transform for Jars and "javaModule=false -> javaModule=true"; the plugin extension object fills the input parameter
project.getDependencies().registerTransform(ExtraModuleInfoTransform.class, t -> {
t.parameters(p -> {
p.setModuleInfo(extension.getModuleInfo());
p.setAutomaticModules(extension.getAutomaticModules());
});
t.getFrom().attribute(artifactType, "jar").attribute(javaModule, false);
t.getTo().attribute(artifactType, "jar").attribute(javaModule, true);
});
}
private boolean isResolvingJavaPluginConfiguration(Configuration configuration) {
if (!configuration.isCanBeResolved()) {
return false;
}
return configuration.getName().endsWith(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME.substring(1))
|| configuration.getName().endsWith(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME.substring(1))
|| configuration.getName().endsWith(JavaPlugin.ANNOTATION_PROCESSOR_CONFIGURATION_NAME.substring(1));
}
}

View file

@ -1,52 +0,0 @@
package org.gradle.sample.transform.javamodules;
import org.gradle.api.Action;
import javax.annotation.Nullable;
import java.util.HashMap;
import java.util.Map;
/**
* A data class to collect all the module information we want to add.
* Here the class is used as extension that can be configured in the build script
* and as input to the ExtraModuleInfoTransform that add the information to Jars.
*/
public class ExtraModuleInfoPluginExtension {
private final Map<String, ModuleInfo> moduleInfo = new HashMap<>();
private final Map<String, String> automaticModules = new HashMap<>();
/**
* Add full module information for a given Jar file.
*/
public void module(String jarName, String moduleName, String moduleVersion) {
module(jarName, moduleName, moduleVersion, null);
}
/**
* Add full module information, including exported packages and dependencies, for a given Jar file.
*/
public void module(String jarName, String moduleName, String moduleVersion, @Nullable Action<? super ModuleInfo> conf) {
ModuleInfo moduleInfo = new ModuleInfo(moduleName, moduleVersion);
if (conf != null) {
conf.execute(moduleInfo);
}
this.moduleInfo.put(jarName, moduleInfo);
}
/**
* Add only an automatic module name to a given jar file.
*/
public void automaticModule(String jarName, String moduleName) {
automaticModules.put(jarName, moduleName);
}
protected Map<String, ModuleInfo> getModuleInfo() {
return moduleInfo;
}
protected Map<String, String> getAutomaticModules() {
return automaticModules;
}
}

View file

@ -1,176 +0,0 @@
package org.gradle.sample.transform.javamodules;
import org.gradle.api.artifacts.transform.InputArtifact;
import org.gradle.api.artifacts.transform.TransformAction;
import org.gradle.api.artifacts.transform.TransformOutputs;
import org.gradle.api.artifacts.transform.TransformParameters;
import org.gradle.api.file.FileSystemLocation;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.Input;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.ModuleVisitor;
import org.objectweb.asm.Opcodes;
import java.io.*;
import java.util.Collections;
import java.util.Map;
import java.util.jar.*;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
/**
* An artifact transform that applies additional information to Jars without module information.
* The transformation fails the build if a Jar does not contain information and no extra information
* was defined for it. This way we make sure that all Jars are turned into modules.
*/
abstract public class ExtraModuleInfoTransform implements TransformAction<ExtraModuleInfoTransform.Parameter> {
public static class Parameter implements TransformParameters, Serializable {
private Map<String, ModuleInfo> moduleInfo = Collections.emptyMap();
private Map<String, String> automaticModules = Collections.emptyMap();
@Input
public Map<String, ModuleInfo> getModuleInfo() {
return moduleInfo;
}
@Input
public Map<String, String> getAutomaticModules() {
return automaticModules;
}
public void setModuleInfo(Map<String, ModuleInfo> moduleInfo) {
this.moduleInfo = moduleInfo;
}
public void setAutomaticModules(Map<String, String> automaticModules) {
this.automaticModules = automaticModules;
}
}
@InputArtifact
protected abstract Provider<FileSystemLocation> getInputArtifact();
@Override
public void transform(TransformOutputs outputs) {
Map<String, ModuleInfo> moduleInfo = getParameters().moduleInfo;
Map<String, String> automaticModules = getParameters().automaticModules;
File originalJar = getInputArtifact().get().getAsFile();
String originalJarName = originalJar.getName();
//Recreate jackson jars as open, non-synthetic modules
if ((isModule(originalJar) && !originalJarName.contains("jackson")) || originalJarName.startsWith("javafx-")) {
outputs.file(originalJar);
} else if (moduleInfo.containsKey(originalJarName)) {
addModuleDescriptor(originalJar, getModuleJar(outputs, originalJar), moduleInfo.get(originalJarName));
} else if (isAutoModule(originalJar)) {
outputs.file(originalJar);
} else if (automaticModules.containsKey(originalJarName)) {
addAutomaticModuleName(originalJar, getModuleJar(outputs, originalJar), automaticModules.get(originalJarName));
} else if(originalJarName.startsWith("kotlin-stdlib-common")) {
//ignore
} else {
throw new RuntimeException("Not a module and no mapping defined: " + originalJarName);
}
}
private boolean isModule(File jar) {
Pattern moduleInfoClassMrjarPath = Pattern.compile("META-INF/versions/\\d+/module-info.class");
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(jar))) {
boolean isMultiReleaseJar = containsMultiReleaseJarEntry(inputStream);
ZipEntry next = inputStream.getNextEntry();
while (next != null) {
if ("module-info.class".equals(next.getName())) {
return true;
}
if (isMultiReleaseJar && moduleInfoClassMrjarPath.matcher(next.getName()).matches()) {
return true;
}
next = inputStream.getNextEntry();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return false;
}
private boolean containsMultiReleaseJarEntry(JarInputStream jarStream) {
Manifest manifest = jarStream.getManifest();
return manifest != null && Boolean.parseBoolean(manifest.getMainAttributes().getValue("Multi-Release"));
}
private boolean isAutoModule(File jar) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(jar))) {
return inputStream.getManifest().getMainAttributes().getValue("Automatic-Module-Name") != null;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private File getModuleJar(TransformOutputs outputs, File originalJar) {
return outputs.file(originalJar.getName().substring(0, originalJar.getName().lastIndexOf('.')) + "-module.jar");
}
private static void addAutomaticModuleName(File originalJar, File moduleJar, String moduleName) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) {
Manifest manifest = inputStream.getManifest();
manifest.getMainAttributes().put(new Attributes.Name("Automatic-Module-Name"), moduleName);
try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), inputStream.getManifest())) {
copyEntries(inputStream, outputStream);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static void addModuleDescriptor(File originalJar, File moduleJar, ModuleInfo moduleInfo) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) {
Manifest manifest = inputStream.getManifest();
if(manifest == null) {
manifest = new Manifest();
}
try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), manifest)) {
copyEntries(inputStream, outputStream);
outputStream.putNextEntry(new JarEntry("module-info.class"));
outputStream.write(addModuleInfo(moduleInfo));
outputStream.closeEntry();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static void copyEntries(JarInputStream inputStream, JarOutputStream outputStream) throws IOException {
JarEntry jarEntry = inputStream.getNextJarEntry();
while (jarEntry != null) {
if(!jarEntry.getName().equals("module-info.class")) {
outputStream.putNextEntry(jarEntry);
outputStream.write(inputStream.readAllBytes());
outputStream.closeEntry();
}
jarEntry = inputStream.getNextJarEntry();
}
}
private static byte[] addModuleInfo(ModuleInfo moduleInfo) {
ClassWriter classWriter = new ClassWriter(0);
classWriter.visit(Opcodes.V9, Opcodes.ACC_MODULE, "module-info", null, null, null);
ModuleVisitor moduleVisitor = classWriter.visitModule(moduleInfo.getModuleName(), Opcodes.ACC_OPEN, moduleInfo.getModuleVersion());
for (String packageName : moduleInfo.getExports()) {
moduleVisitor.visitExport(packageName.replace('.', '/'), 0);
}
moduleVisitor.visitRequire("java.base", 0, null);
for (String requireName : moduleInfo.getRequires()) {
moduleVisitor.visitRequire(requireName, 0, null);
}
for (String requireName : moduleInfo.getRequiresTransitive()) {
moduleVisitor.visitRequire(requireName, Opcodes.ACC_TRANSITIVE, null);
}
for (String usesName : moduleInfo.getUses()) {
moduleVisitor.visitUse(usesName.replace('.', '/'));
}
moduleVisitor.visitEnd();
classWriter.visitEnd();
return classWriter.toByteArray();
}
}

View file

@ -1,62 +0,0 @@
package org.gradle.sample.transform.javamodules;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* Data class to hold the information that should be added as module-info.class to an existing Jar file.
*/
public class ModuleInfo implements Serializable {
private String moduleName;
private String moduleVersion;
private List<String> exports = new ArrayList<>();
private List<String> requires = new ArrayList<>();
private List<String> requiresTransitive = new ArrayList<>();
private List<String> uses = new ArrayList<>();
ModuleInfo(String moduleName, String moduleVersion) {
this.moduleName = moduleName;
this.moduleVersion = moduleVersion;
}
public void exports(String exports) {
this.exports.add(exports);
}
public void requires(String requires) {
this.requires.add(requires);
}
public void requiresTransitive(String requiresTransitive) {
this.requiresTransitive.add(requiresTransitive);
}
public void uses(String uses) {
this.uses.add(uses);
}
public String getModuleName() {
return moduleName;
}
protected String getModuleVersion() {
return moduleVersion;
}
protected List<String> getExports() {
return exports;
}
protected List<String> getRequires() {
return requires;
}
protected List<String> getRequiresTransitive() {
return requiresTransitive;
}
protected List<String> getUses() {
return uses;
}
}

View file

@ -32,7 +32,6 @@ package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetectorPlugin; import com.google.gradle.osdetector.OsDetectorPlugin;
import org.gradle.api.Plugin; import org.gradle.api.Plugin;
import org.gradle.api.Project; import org.gradle.api.Project;
import org.javamodularity.moduleplugin.ModuleSystemPlugin;
import org.openjfx.gradle.tasks.ExecTask; import org.openjfx.gradle.tasks.ExecTask;
public class JavaFXPlugin implements Plugin<Project> { public class JavaFXPlugin implements Plugin<Project> {
@ -40,10 +39,9 @@ public class JavaFXPlugin implements Plugin<Project> {
@Override @Override
public void apply(Project project) { public void apply(Project project) {
project.getPlugins().apply(OsDetectorPlugin.class); project.getPlugins().apply(OsDetectorPlugin.class);
project.getPlugins().apply(ModuleSystemPlugin.class);
project.getExtensions().create("javafx", JavaFXOptions.class, project); project.getExtensions().create("javafx", JavaFXOptions.class, project);
project.getTasks().create("configJavafxRun", ExecTask.class, project); project.getTasks().register("configJavafxRun", ExecTask.class, project);
} }
} }

View file

@ -33,27 +33,19 @@ import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException; import org.gradle.api.GradleException;
import org.gradle.api.Project; import org.gradle.api.Project;
import org.gradle.api.file.FileCollection; import org.gradle.api.file.FileCollection;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.ApplicationPlugin; import org.gradle.api.plugins.ApplicationPlugin;
import org.gradle.api.tasks.JavaExec; import org.gradle.api.tasks.JavaExec;
import org.gradle.api.tasks.TaskAction; import org.gradle.api.tasks.TaskAction;
import org.javamodularity.moduleplugin.extensions.RunModuleOptions;
import org.openjfx.gradle.JavaFXModule; import org.openjfx.gradle.JavaFXModule;
import org.openjfx.gradle.JavaFXOptions; import org.openjfx.gradle.JavaFXOptions;
import org.openjfx.gradle.JavaFXPlatform; import org.openjfx.gradle.JavaFXPlatform;
import javax.inject.Inject; import javax.inject.Inject;
import java.io.File; import java.io.File;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.TreeSet; import java.util.TreeSet;
public class ExecTask extends DefaultTask { public class ExecTask extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(ExecTask.class);
private final Project project; private final Project project;
private JavaExec execTask; private JavaExec execTask;
@ -78,37 +70,11 @@ public class ExecTask extends DefaultTask {
var definedJavaFXModuleNames = new TreeSet<>(javaFXOptions.getModules()); var definedJavaFXModuleNames = new TreeSet<>(javaFXOptions.getModules());
if (!definedJavaFXModuleNames.isEmpty()) { if (!definedJavaFXModuleNames.isEmpty()) {
RunModuleOptions moduleOptions = execTask.getExtensions().findByType(RunModuleOptions.class);
final FileCollection classpathWithoutJavaFXJars = execTask.getClasspath().filter( final FileCollection classpathWithoutJavaFXJars = execTask.getClasspath().filter(
jar -> Arrays.stream(JavaFXModule.values()).noneMatch(javaFXModule -> jar.getName().contains(javaFXModule.getArtifactName())) jar -> Arrays.stream(JavaFXModule.values()).noneMatch(javaFXModule -> jar.getName().contains(javaFXModule.getArtifactName()))
); );
final FileCollection javaFXPlatformJars = execTask.getClasspath().filter(jar -> isJavaFXJar(jar, javaFXOptions.getPlatform())); final FileCollection javaFXPlatformJars = execTask.getClasspath().filter(jar -> isJavaFXJar(jar, javaFXOptions.getPlatform()));
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
if (moduleOptions != null) {
LOGGER.info("Modular JavaFX application found");
// Remove empty JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
definedJavaFXModuleNames.forEach(javaFXModule -> moduleOptions.getAddModules().add(javaFXModule));
} else {
LOGGER.info("Non-modular JavaFX application found");
// Remove all JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars);
var javaFXModuleJvmArgs = List.of("--module-path", javaFXPlatformJars.getAsPath());
var jvmArgs = new ArrayList<String>();
jvmArgs.add("--add-modules");
jvmArgs.add(String.join(",", definedJavaFXModuleNames));
List<String> execJvmArgs = execTask.getJvmArgs();
if (execJvmArgs != null) {
jvmArgs.addAll(execJvmArgs);
}
jvmArgs.addAll(javaFXModuleJvmArgs);
execTask.setJvmArgs(jvmArgs);
}
} }
} else { } else {
throw new GradleException("Run task not found. Please, make sure the Application plugin is applied"); throw new GradleException("Run task not found. Please, make sure the Application plugin is applied");

View file

@ -12,17 +12,18 @@ Work on resolving both of these issues is ongoing.
### Install Java ### Install Java
Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release. Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release.
For v1.6.6 and later, this is Eclipse Temurin 18.0.1+10. For v1.6.6 to v1.9.1, this was Eclipse Temurin 18.0.1+10. For v2.0.0 and later, Eclipse Temurin 22.0.2+9 is used.
#### Java from Adoptium github repo #### Java from Adoptium github repo
It is available for all supported platforms from [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10). It is available for all supported platforms from [Eclipse Temurin 22.0.2+9](https://github.com/adoptium/temurin22-binaries/releases/tag/jdk-22.0.2%2B9).
For reference, the downloads are as follows: For reference, the downloads are as follows:
- [Linux x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_linux_hotspot_18.0.1_10.tar.gz) - [Linux x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_linux_hotspot_22.0.2_9.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_mac_hotspot_18.0.1_10.tar.gz) - [Linux aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_linux_hotspot_22.0.2_9.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_aarch64_mac_hotspot_18.0.1_10.tar.gz) - [MacOS x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_mac_hotspot_22.0.2_9.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_windows_hotspot_18.0.1_10.zip) - [MacOS aarch64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_aarch64_mac_hotspot_22.0.2_9.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin22-binaries/releases/download/jdk-22.0.2%2B9/OpenJDK22U-jdk_x64_windows_hotspot_22.0.2_9.zip)
#### Java from Adoptium deb repo #### Java from Adoptium deb repo
@ -42,7 +43,7 @@ Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
``` ```
gpg --import --import-options show-only adoptium.asc gpg --import --import-options show-only adoptium.asc
``` ```
If key doesn't match, do not procede. If key doesn't match, do not proceed.
Add Adoptium PGP key to a the keyring shared folder: Add Adoptium PGP key to a the keyring shared folder:
```sh ```sh
@ -57,7 +58,7 @@ echo "deb [signed-by=/usr/share/keyrings/adoptium.asc] https://packages.adoptium
Update cache, install the desired temurin version and configure java to be linked to this same version: Update cache, install the desired temurin version and configure java to be linked to this same version:
``` ```
sudo apt update -y sudo apt update -y
sudo apt-get install -y temurin-18-jdk=18.0.1+10 sudo apt-get install -y temurin-22-jdk=22.0.2+9
sudo update-alternatives --config java sudo update-alternatives --config java
``` ```
@ -67,7 +68,7 @@ A alternative option for all platforms is to use the [sdkman.io](https://sdkman.
See the installation [instructions here](https://sdkman.io/install). See the installation [instructions here](https://sdkman.io/install).
Once installed, run Once installed, run
```shell ```shell
sdk install java 18.0.1-tem sdk install java 22.0.2-tem
``` ```
### Other requirements ### Other requirements
@ -82,7 +83,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify: First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell ```shell
GIT_TAG="1.7.1" GIT_TAG="2.3.0"
``` ```
The project can then be initially cloned as follows: The project can then be initially cloned as follows:

2
drongo

@ -1 +1 @@
Subproject commit fa18ec9d458bb17221fb01b6be9f4eceb354a156 Subproject commit e975cbe6f8d8574785124e6db5780d0541e20024

Binary file not shown.

View file

@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

44
gradlew vendored
View file

@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -80,13 +82,11 @@ do
esac esac
done done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # This is normally unused
# shellcheck disable=SC2034
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@ -133,22 +132,29 @@ location of your Java installation."
fi fi
else else
JAVACMD=java JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the Please set the JAVA_HOME variable in your environment to match the
location of your Java installation." location of your Java installation."
fi
fi fi
# Increase the maximum file descriptors if we can. # Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
case $MAX_FD in #( case $MAX_FD in #(
'' | soft) :;; #( '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@ -165,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@ -193,16 +198,19 @@ if "$cygwin" || "$msys" ; then
done done
fi fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
# shell script including quotes and variable substitutions, so put them in DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded. # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

26
gradlew.bat vendored
View file

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@ -26,6 +28,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@ -42,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
@ -56,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell

1
lark Submodule

@ -0,0 +1 @@
Subproject commit 10e8d9cd4bbe9fde4dd93c059e2a9faeec6be3e0

48
repackage.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
set -e # Exit on any error
# Define paths
BUILD_DIR="build"
JPACKAGE_DIR="$BUILD_DIR/jpackage"
TEMP_DIR="$BUILD_DIR/repackage"
# Find the .deb file in build/jpackage (assuming there is only one)
DEB_FILE=$(find "$JPACKAGE_DIR" -type f -name "*.deb" -print -quit)
# Check if a .deb file was found
if [ -z "$DEB_FILE" ]; then
echo "Error: No .deb file found in $JPACKAGE_DIR"
exit 1
fi
# Extract the filename from the path for later use
DEB_FILENAME=$(basename "$DEB_FILE")
echo "Found .deb file: $DEB_FILENAME"
# Create a temp directory inside build to avoid file conflicts
mkdir -p "$TEMP_DIR"
cd "$TEMP_DIR"
# Extract the .deb file contents
ar x "../../$DEB_FILE"
# Decompress zst files to tar
unzstd control.tar.zst
unzstd data.tar.zst
# Compress tar files to xz
xz -c control.tar > control.tar.xz
xz -c data.tar > data.tar.xz
# Remove the original .deb file
rm "../../$DEB_FILE"
# Create the new .deb file with xz compression in the original location
ar cr "../../$DEB_FILE" debian-binary control.tar.xz data.tar.xz
# Clean up temp files
cd ../..
rm -rf "$TEMP_DIR"
echo "Repackaging complete: $DEB_FILENAME"

View file

@ -1,3 +1,4 @@
rootProject.name = 'sparrow' rootProject.name = 'sparrow'
include 'drongo' include 'drongo'
include 'lark'

View file

@ -0,0 +1,3 @@
mime-type=application/pgp-signature
extension=asc
description=ASCII Armored File

View file

@ -0,0 +1,12 @@
Package: sparrowserver
Version: ${version}-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: ${arch}
Conflicts: sparrow (<= 2.1.4)
Replaces: sparrow (<= 2.1.4)
Provides: sparrowserver
Description: Sparrow Server
Depends: libc6, zlib1g
Installed-Size: ${size}

View file

@ -0,0 +1,85 @@
Summary: Sparrow Server
Name: sparrowserver
Version: ${version}
Release: 1
License: ASL 2.0
Vendor: Unknown
%if "x" != "x"
URL: https://sparrowwallet.com
%endif
%if "x/opt" != "x"
Prefix: /opt
%endif
Provides: sparrowserver
Obsoletes: sparrow <= 2.1.4
%if "xutils" != "x"
Group: utils
%endif
Autoprov: 0
Autoreq: 0
#comment line below to enable effective jar compression
#it could easily get your package size from 40 to 15Mb but
#build time will substantially increase and it may require unpack200/system java to install
%define __jar_repack %{nil}
# on RHEL we got unwanted improved debugging enhancements
%define _build_id_links none
%define package_filelist %{_builddir}/%{name}.files
%define app_filelist %{_builddir}/%{name}.app.files
%define filesystem_filelist %{_builddir}/%{name}.filesystem.files
%define default_filesystem / /opt /usr /usr/bin /usr/lib /usr/local /usr/local/bin /usr/local/lib
%description
Sparrow Server
%global __os_install_post %{nil}
%prep
%build
%install
rm -rf %{buildroot}
install -d -m 755 %{buildroot}/opt/sparrowserver
cp -r %{_sourcedir}/opt/sparrowserver/* %{buildroot}/opt/sparrowserver
if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then
install -d -m 755 %{buildroot}/lib/systemd/system
cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system
fi
%if "x%{_rpmdir}/../../LICENSE" != "x"
%define license_install_file %{_defaultlicensedir}/%{name}-%{version}/%{basename:%{_rpmdir}/../../LICENSE}
install -d -m 755 "%{buildroot}%{dirname:%{license_install_file}}"
install -m 644 "%{_rpmdir}/../../LICENSE" "%{buildroot}%{license_install_file}"
%endif
(cd %{buildroot} && find . -path ./lib/systemd -prune -o -type d -print) | sed -e 's/^\.//' -e '/^$/d' | sort > %{app_filelist}
{ rpm -ql filesystem || echo %{default_filesystem}; } | sort > %{filesystem_filelist}
comm -23 %{app_filelist} %{filesystem_filelist} > %{package_filelist}
sed -i -e 's/.*/%dir "&"/' %{package_filelist}
(cd %{buildroot} && find . -not -type d) | sed -e 's/^\.//' -e 's/.*/"&"/' >> %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
sed -i -e 's|"%{license_install_file}"||' -e '/^$/d' %{package_filelist}
%endif
%files -f %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
%license "%{license_install_file}"
%endif
%post
package_type=rpm
%pre
package_type=rpm
%preun
package_type=rpm
%clean

View file

@ -1,9 +1,11 @@
[Desktop Entry] [Desktop Entry]
Name=Sparrow Name=Sparrow
Comment=Sparrow Comment=Sparrow
Exec=/opt/sparrow/bin/Sparrow %U Exec=/opt/sparrowwallet/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png Icon=/opt/sparrowwallet/lib/Sparrow.png
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Unknown Categories=Finance;Network;
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning MimeType=application/psbt;application/bitcoin-transaction;application/pgp-signature;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
StartupWMClass=Sparrow
SingleMainWindow=true

View file

@ -0,0 +1,12 @@
Package: sparrowwallet
Version: ${version}-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: ${arch}
Provides: sparrowwallet
Conflicts: sparrow (<= 2.1.4)
Replaces: sparrow (<= 2.1.4)
Description: Sparrow Wallet
Depends: libasound2, libbsd0, libc6, libmd0, libx11-6, libxau6, libxcb1, libxdmcp6, libxext6, libxi6, libxrender1, libxtst6, xdg-utils
Installed-Size: ${size}

View file

@ -0,0 +1,49 @@
#!/bin/sh
# postinst script for sparrowwallet
#
# see: dh_installdeb(1)
set -e
# summary of how this script can be called:
# * <postinst> `configure' <most-recently-configured-version>
# * <old-postinst> `abort-upgrade' <new version>
# * <conflictor's-postinst> `abort-remove' `in-favour' <package>
# <new-version>
# * <postinst> `abort-remove'
# * <deconfigured's-postinst> `abort-deconfigure' `in-favour'
# <failed-install-package> <version> `removing'
# <conflicting-package> <version>
# for details, see https://www.debian.org/doc/debian-policy/ or
# the debian-policy package
package_type=deb
case "$1" in
configure)
xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
fi
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
fi
if [ -w /sys/devices ] && [ -w /sys/kernel/uevent_seqnum ] && [ -x /bin/udevadm ]; then
/bin/udevadm control --reload
/bin/udevadm trigger
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

View file

@ -0,0 +1,260 @@
Summary: Sparrow
Name: sparrowwallet
Version: ${version}
Release: 1
License: ASL 2.0
Vendor: Unknown
%if "x" != "x"
URL: https://sparrowwallet.com
%endif
%if "x/opt" != "x"
Prefix: /opt
%endif
Provides: sparrowwallet
Obsoletes: sparrow <= 2.1.4
%if "xutils" != "x"
Group: utils
%endif
Autoprov: 0
Autoreq: 0
%if "xxdg-utils" != "x" || "x" != "x"
Requires: xdg-utils
%endif
#comment line below to enable effective jar compression
#it could easily get your package size from 40 to 15Mb but
#build time will substantially increase and it may require unpack200/system java to install
%define __jar_repack %{nil}
# on RHEL we got unwanted improved debugging enhancements
%define _build_id_links none
%define package_filelist %{_builddir}/%{name}.files
%define app_filelist %{_builddir}/%{name}.app.files
%define filesystem_filelist %{_builddir}/%{name}.filesystem.files
%define default_filesystem / /opt /usr /usr/bin /usr/lib /usr/local /usr/local/bin /usr/local/lib
%description
Sparrow Wallet
%global __os_install_post %{nil}
%prep
%build
%install
rm -rf %{buildroot}
install -d -m 755 %{buildroot}/opt/sparrowwallet
cp -r %{_sourcedir}/opt/sparrowwallet/* %{buildroot}/opt/sparrowwallet
if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then
install -d -m 755 %{buildroot}/lib/systemd/system
cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system
fi
%if "x%{_rpmdir}/../../LICENSE" != "x"
%define license_install_file %{_defaultlicensedir}/%{name}-%{version}/%{basename:%{_rpmdir}/../../LICENSE}
install -d -m 755 "%{buildroot}%{dirname:%{license_install_file}}"
install -m 644 "%{_rpmdir}/../../LICENSE" "%{buildroot}%{license_install_file}"
%endif
(cd %{buildroot} && find . -path ./lib/systemd -prune -o -type d -print) | sed -e 's/^\.//' -e '/^$/d' | sort > %{app_filelist}
{ rpm -ql filesystem || echo %{default_filesystem}; } | sort > %{filesystem_filelist}
comm -23 %{app_filelist} %{filesystem_filelist} > %{package_filelist}
sed -i -e 's/.*/%dir "&"/' %{package_filelist}
(cd %{buildroot} && find . -not -type d) | sed -e 's/^\.//' -e 's/.*/"&"/' >> %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
sed -i -e 's|"%{license_install_file}"||' -e '/^$/d' %{package_filelist}
%endif
%files -f %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
%license "%{license_install_file}"
%endif
%post
package_type=rpm
xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
fi
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
fi
if [ -w /sys/devices ] && [ -w /sys/kernel/uevent_seqnum ] && [ -x /bin/udevadm ]; then
/bin/udevadm control --reload
/bin/udevadm trigger
fi
%pre
package_type=rpm
file_belongs_to_single_package ()
{
if [ ! -e "$1" ]; then
false
elif [ "$package_type" = rpm ]; then
test `rpm -q --whatprovides "$1" | wc -l` = 1
elif [ "$package_type" = deb ]; then
test `dpkg -S "$1" | wc -l` = 1
else
exit 1
fi
}
do_if_file_belongs_to_single_package ()
{
local file="$1"
shift
if file_belongs_to_single_package "$file"; then
"$@"
fi
}
if [ "$1" -gt 1 ]; then
:;
fi
%preun
package_type=rpm
file_belongs_to_single_package ()
{
if [ ! -e "$1" ]; then
false
elif [ "$package_type" = rpm ]; then
test `rpm -q --whatprovides "$1" | wc -l` = 1
elif [ "$package_type" = deb ]; then
test `dpkg -S "$1" | wc -l` = 1
else
exit 1
fi
}
do_if_file_belongs_to_single_package ()
{
local file="$1"
shift
if file_belongs_to_single_package "$file"; then
"$@"
fi
}
#
# Remove $1 desktop file from the list of default handlers for $2 mime type
# in $3 file dumping output to stdout.
#
desktop_filter_out_default_mime_handler ()
{
local defaults_list="$3"
local desktop_file="$1"
local mime_type="$2"
awk -f- "$defaults_list" <<EOF
BEGIN {
mime_type="$mime_type"
mime_type_regexp="~" mime_type "="
desktop_file="$desktop_file"
}
\$0 ~ mime_type {
\$0 = substr(\$0, length(mime_type) + 2);
split(\$0, desktop_files, ";")
remaining_desktop_files
counter=0
for (idx in desktop_files) {
if (desktop_files[idx] != desktop_file) {
++counter;
}
}
if (counter) {
printf mime_type "="
for (idx in desktop_files) {
if (desktop_files[idx] != desktop_file) {
printf desktop_files[idx]
if (--counter) {
printf ";"
}
}
}
printf "\n"
}
next
}
{ print }
EOF
}
#
# Remove $2 desktop file from the list of default handlers for $@ mime types
# in $1 file.
# Result is saved in $1 file.
#
desktop_uninstall_default_mime_handler_0 ()
{
local defaults_list=$1
shift
[ -f "$defaults_list" ] || return 0
local desktop_file="$1"
shift
tmpfile1=$(mktemp)
tmpfile2=$(mktemp)
cat "$defaults_list" > "$tmpfile1"
local v
local update=
for mime in "$@"; do
desktop_filter_out_default_mime_handler "$desktop_file" "$mime" "$tmpfile1" > "$tmpfile2"
v="$tmpfile2"
tmpfile2="$tmpfile1"
tmpfile1="$v"
if ! diff -q "$tmpfile1" "$tmpfile2" > /dev/null; then
update=yes
desktop_trace Remove $desktop_file default handler for $mime mime type from $defaults_list file
fi
done
if [ -n "$update" ]; then
cat "$tmpfile1" > "$defaults_list"
desktop_trace "$defaults_list" file updated
fi
rm -f "$tmpfile1" "$tmpfile2"
}
#
# Remove $1 desktop file from the list of default handlers for $@ mime types
# in all known system defaults lists.
#
desktop_uninstall_default_mime_handler ()
{
for f in /usr/share/applications/defaults.list /usr/local/share/applications/defaults.list; do
desktop_uninstall_default_mime_handler_0 "$f" "$@"
done
}
desktop_trace ()
{
echo "$@"
}
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop xdg-desktop-menu uninstall /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml xdg-mime uninstall /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop desktop_uninstall_default_mime_handler sparrowwallet-Sparrow.desktop application/psbt application/bitcoin-transaction application/pgp-signature x-scheme-handler/bitcoin x-scheme-handler/auth47 x-scheme-handler/lightning
%clean

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.7.1</string> <string>2.3.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories --> <!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@ -33,8 +33,12 @@
<string>Copyright (C) 2021</string> <string>Copyright (C) 2021</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<string>true</string> <string>true</string>
<key>NSCameraUseContinuityCameraDeviceType</key>
<true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Sparrow requires access to the camera in order to scan QR codes</string> <string>Sparrow requires access to the camera in order to scan QR codes</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Sparrow requires access to the local network in order to connect to your configured server</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@ -94,6 +98,21 @@
<key>UTTypeIconFile</key> <key>UTTypeIconFile</key>
<string>sparrow.icns</string> <string>sparrow.icns</string>
</dict> </dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.sparrowwallet.asc</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>asc</string>
</array>
</dict>
<key>UTTypeDescription</key>
<string>ASCII Armored File</string>
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
</array> </array>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe; import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort; import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.bip47.PaymentCode;
@ -12,6 +13,7 @@ import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key; import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.DialogImage;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog; import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.Auth47; import com.sparrowwallet.sparrow.net.Auth47;
@ -24,9 +26,8 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*; import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.paynym.PayNymService; import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import com.sparrowwallet.sparrow.soroban.SorobanServices; import io.reactivex.subjects.PublishSubject;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
@ -34,6 +35,7 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener; import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.concurrent.ScheduledService; import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task; import javafx.concurrent.Task;
import javafx.concurrent.Worker; import javafx.concurrent.Worker;
import javafx.fxml.FXMLLoader; import javafx.fxml.FXMLLoader;
@ -43,14 +45,12 @@ import javafx.scene.Scene;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.Dialog; import javafx.scene.control.Dialog;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCode;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Screen; import javafx.stage.Screen;
import javafx.stage.Stage; import javafx.stage.Stage;
import javafx.stage.Window; import javafx.stage.Window;
import javafx.util.Duration; import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -64,11 +64,16 @@ import java.io.IOException;
import java.net.*; import java.net.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
public class AppServices { public class AppServices {
private static final Logger log = LoggerFactory.getLogger(AppServices.class); private static final Logger log = LoggerFactory.getLogger(AppServices.class);
@ -79,23 +84,22 @@ public class AppServices {
private static final int RATES_PERIOD_SECS = 5 * 60; private static final int RATES_PERIOD_SECS = 5 * 60;
private static final int VERSION_CHECK_PERIOD_HOURS = 24; private static final int VERSION_CHECK_PERIOD_HOURS = 24;
private static final int CONNECTION_DELAY_SECS = 2; private static final int CONNECTION_DELAY_SECS = 2;
private static final int RATES_DELAY_SECS_DEFAULT = 2;
private static final int RATES_DELAY_SECS_WINDOWS = 5;
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO; private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD"); private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default"; private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50); public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L); private static final List<Double> LONG_FEE_RATES_RANGE = List.of(1d, 2d, 4d, 8d, 16d, 32d, 64d, 128d, 256d, 512d, 1024d, 2048d, 4096d, 8192d);
public static final double FALLBACK_FEE_RATE = 20000d / 1000; public static final double FALLBACK_FEE_RATE = 20000d / 1000;
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
private static AppServices INSTANCE; private static AppServices INSTANCE;
private final WhirlpoolServices whirlpoolServices = new WhirlpoolServices(); private final InteractionServices interactionServices;
private final SorobanServices sorobanServices = new SorobanServices(); private static HttpClientService httpClientService;
private InteractionServices interactionServices;
private static PayNymService payNymService;
private final Application application; private final Application application;
@ -103,6 +107,8 @@ public class AppServices {
private TrayManager trayManager; private TrayManager trayManager;
private final PublishSubject<NewBlockEvent> newBlockSubject = PublishSubject.create();
private static Image windowIcon; private static Image windowIcon;
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false); private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
@ -111,6 +117,8 @@ public class AppServices {
private ElectrumServer.ConnectionService connectionService; private ElectrumServer.ConnectionService connectionService;
private ElectrumServer.FeeRatesService feeRatesService;
private Hwi.ScheduledEnumerateService deviceEnumerateService; private Hwi.ScheduledEnumerateService deviceEnumerateService;
private VersionCheckService versionCheckService; private VersionCheckService versionCheckService;
@ -123,12 +131,18 @@ public class AppServices {
private static BlockHeader latestBlockHeader; private static BlockHeader latestBlockHeader;
private static final Map<Integer, BlockSummary> blockSummaries = new ConcurrentHashMap<>();
private static Map<Integer, Double> targetBlockFeeRates; private static Map<Integer, Double> targetBlockFeeRates;
private static Double nextBlockMedianFeeRate;
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>(); private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
private static Double minimumRelayFeeRate; private static Double minimumRelayFeeRate;
private static Double serverMinimumRelayFeeRate;
private static CurrencyRate fiatCurrencyExchangeRate; private static CurrencyRate fiatCurrencyExchangeRate;
private static List<Device> devices; private static List<Device> devices;
@ -159,6 +173,11 @@ public class AppServices {
connectionService.cancel(); connectionService.cancel();
ratesService.cancel(); ratesService.cancel();
versionCheckService.cancel(); versionCheckService.cancel();
if(httpClientService != null) {
HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
shutdownService.start();
}
} }
} }
}; };
@ -174,20 +193,26 @@ public class AppServices {
private AppServices(Application application, InteractionServices interactionServices) { private AppServices(Application application, InteractionServices interactionServices) {
this.application = application; this.application = application;
this.interactionServices = interactionServices; this.interactionServices = interactionServices;
newBlockSubject.buffer(4, TimeUnit.SECONDS)
.filter(newBlockEvents -> !newBlockEvents.isEmpty())
.observeOn(JavaFxScheduler.platform())
.subscribe(this::fetchBlockSummaries, exception -> log.error("Error fetching block summaries", exception));
EventManager.get().register(this); EventManager.get().register(this);
EventManager.get().register(whirlpoolServices);
EventManager.get().register(sorobanServices);
} }
public void start() { public void start() {
Config config = Config.get(); Config config = Config.get();
connectionService = createConnectionService(); connectionService = createConnectionService();
feeRatesService = createFeeRatesService();
ratesService = createRatesService(config.getExchangeSource(), config.getFiatCurrency()); ratesService = createRatesService(config.getExchangeSource(), config.getFiatCurrency());
versionCheckService = createVersionCheckService(); versionCheckService = createVersionCheckService();
torService = createTorService(); torService = createTorService();
preventSleepService = createPreventSleepService(); preventSleepService = createPreventSleepService();
onlineProperty.addListener(onlineServicesListener); onlineProperty.addListener(onlineServicesListener);
minimumRelayFeeRate = getConfiguredMinimumRelayFeeRate(config);
if(config.getMode() == Mode.ONLINE) { if(config.getMode() == Mode.ONLINE) {
if(config.requiresInternalTor()) { if(config.requiresInternalTor()) {
@ -195,6 +220,8 @@ public class AppServices {
} else { } else {
restartServices(); restartServices();
} }
} else {
EventManager.get().post(new DisconnectionEvent());
} }
addURIHandlers(); addURIHandlers();
@ -246,13 +273,13 @@ public class AppServices {
versionCheckService.cancel(); versionCheckService.cancel();
} }
if(payNymService != null) { if(httpClientService != null) {
PayNymService.ShutdownService shutdownService = new PayNymService.ShutdownService(payNymService); HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
shutdownService.start(); shutdownService.start();
} }
if(Tor.getDefault() != null) { if(Tor.getDefault() != null) {
Tor.getDefault().shutdown(); Tor.getDefault().close();
} }
} }
@ -278,8 +305,9 @@ public class AppServices {
onlineProperty.setValue(true); onlineProperty.setValue(true);
onlineProperty.addListener(onlineServicesListener); onlineProperty.addListener(onlineServicesListener);
if(connectionService.getValue() != null) { FeeRatesUpdatedEvent event = connectionService.getValue();
EventManager.get().post(connectionService.getValue()); if(event != null) {
EventManager.get().post(event);
} }
}); });
connectionService.setOnFailed(failEvent -> { connectionService.setOnFailed(failEvent -> {
@ -290,20 +318,19 @@ public class AppServices {
connectionService.setRestartOnFailure(false); connectionService.setRestartOnFailure(false);
} }
if(failEvent.getSource().getException() instanceof TlsServerException && failEvent.getSource().getException().getCause() != null) { if(failEvent.getSource().getException() instanceof TlsServerException tlsServerException && failEvent.getSource().getException().getCause() != null) {
TlsServerException tlsServerException = (TlsServerException)failEvent.getSource().getException();
connectionService.setRestartOnFailure(false); connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) { if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert(); File crtFile = Config.get().getElectrumServerCert();
if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) { if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." + AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." +
"\n\nThis may indicate a man-in-the-middle attack!" + "\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
"\n\nChange the configured server certificate if you would like to proceed."); "\n\nChange the configured server certificate if you would like to proceed.");
} else { } else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost()); crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(crtFile != null) { if(crtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." + Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" + "\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES); "\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) { if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
if(crtFile.delete()) { if(crtFile.delete()) {
@ -315,10 +342,19 @@ public class AppServices {
} }
} }
} }
} else if(tlsServerException.getCause().getCause() instanceof UnknownCertificateExpiredException expiredException) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " has expired. "
+ tlsServerException.getMessage() + "." +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
Storage.saveCertificate(tlsServerException.getServer().getHost(), expiredException.getCertificate());
Platform.runLater(() -> restartService(connectionService));
return;
}
} }
} }
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().requiresTor()) { if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().isAutoSwitchProxy() && Config.get().requiresTor()) {
Config.get().setUseProxy(false); Config.get().setUseProxy(false);
Platform.runLater(() -> restartService(torService)); Platform.runLater(() -> restartService(torService));
return; return;
@ -342,10 +378,21 @@ public class AppServices {
return connectionService; return connectionService;
} }
private ElectrumServer.FeeRatesService createFeeRatesService() {
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService();
feeRatesService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(feeRatesService.getValue());
});
return feeRatesService;
}
private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) { private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) {
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService( ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(
exchangeSource == null ? DEFAULT_EXCHANGE_SOURCE : exchangeSource, exchangeSource == null ? DEFAULT_EXCHANGE_SOURCE : exchangeSource,
currency == null ? DEFAULT_FIAT_CURRENCY : currency); currency == null ? DEFAULT_FIAT_CURRENCY : currency);
//Delay startup on first run, Windows requires a longer delay
ratesService.setDelay(OsType.getCurrent() == OsType.WINDOWS ? Duration.seconds(RATES_DELAY_SECS_WINDOWS) : Duration.seconds(RATES_DELAY_SECS_DEFAULT));
ratesService.setPeriod(Duration.seconds(RATES_PERIOD_SECS)); ratesService.setPeriod(Duration.seconds(RATES_PERIOD_SECS));
ratesService.setRestartOnFailure(true); ratesService.setRestartOnFailure(true);
@ -406,23 +453,6 @@ public class AppServices {
EventManager.get().post(new TorReadyStatusEvent()); EventManager.get().post(new TorReadyStatusEvent());
}); });
torService.setOnFailed(workerStateEvent -> { torService.setOnFailed(workerStateEvent -> {
Throwable exception = workerStateEvent.getSource().getException();
if(exception instanceof TorServerAlreadyBoundException) {
String proxyServer = Config.get().getProxyServer();
if(proxyServer == null || proxyServer.equals("")) {
proxyServer = "localhost:9050";
Config.get().setProxyServer(proxyServer);
}
if(proxyServer.equals("localhost:9050") || proxyServer.equals("127.0.0.1:9050")) {
Config.get().setUseProxy(true);
torService.cancel();
restartServices();
EventManager.get().post(new TorExternalStatusEvent());
return;
}
}
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException())); EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
}); });
@ -462,6 +492,26 @@ public class AppServices {
} }
} }
private void fetchFeeRates() {
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
}
private void fetchBlockSummaries(List<NewBlockEvent> newBlockEvents) {
if(isConnected()) {
ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents);
blockSummaryService.setOnSucceeded(_ -> {
EventManager.get().post(blockSummaryService.getValue());
});
blockSummaryService.setOnFailed(failedState -> {
log.error("Error fetching block summaries", failedState.getSource().getException());
});
blockSummaryService.start();
}
}
public static boolean isTorRunning() { public static boolean isTorRunning() {
return Tor.getDefault() != null; return Tor.getDefault() != null;
} }
@ -477,13 +527,12 @@ public class AppServices {
public static Proxy getProxy(String proxyCircuitId) { public static Proxy getProxy(String proxyCircuitId) {
Config config = Config.get(); Config config = Config.get();
Proxy proxy = null; Proxy proxy = null;
if(config.isUseProxy()) { if(config.isUseProxy() && config.getProxyServer() != null) {
HostAndPort proxyHostAndPort = HostAndPort.fromString(config.getProxyServer()); HostAndPort proxyHostAndPort = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT)); InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress); proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
} else if(AppServices.isTorRunning()) { } else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT); proxy = Tor.getDefault().getProxy();
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
} }
//Setting new proxy authentication credentials will force a new Tor circuit to be created //Setting new proxy authentication credentials will force a new Tor circuit to be created
@ -510,35 +559,26 @@ public class AppServices {
return INSTANCE; return INSTANCE;
} }
public static WhirlpoolServices getWhirlpoolServices() {
return get().whirlpoolServices;
}
public static SorobanServices getSorobanServices() {
return get().sorobanServices;
}
public static InteractionServices getInteractionServices() { public static InteractionServices getInteractionServices() {
return get().interactionServices; return get().interactionServices;
} }
public static PayNymService getPayNymService() { public static HttpClientService getHttpClientService() {
if(payNymService == null) { HostAndPort torProxy = getTorProxy();
HostAndPort torProxy = getTorProxy(); if(httpClientService == null) {
payNymService = new PayNymService(torProxy); httpClientService = new HttpClientService(torProxy);
} else { } else {
HostAndPort torProxy = getTorProxy(); if(!Objects.equals(httpClientService.getTorProxy(), torProxy)) {
if(!Objects.equals(payNymService.getTorProxy(), torProxy)) { httpClientService.setTorProxy(getTorProxy());
payNymService.setTorProxy(getTorProxy());
} }
} }
return payNymService; return httpClientService;
} }
public static HostAndPort getTorProxy() { public static HostAndPort getTorProxy() {
return AppServices.isTorRunning() ? return AppServices.isTorRunning() ?
HostAndPort.fromParts("localhost", TorService.PROXY_PORT) : Tor.getDefault().getProxyHostAndPort() :
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer())); (Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer()));
} }
@ -566,6 +606,34 @@ public class AppServices {
} }
} }
public static void runAfterDelay(long delay, Runnable runnable) {
if(delay <= 0) {
if(Platform.isFxApplicationThread()) {
runnable.run();
} else {
Platform.runLater(runnable);
}
} else {
ScheduledService<Void> delayService = new ScheduledService<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
return null;
}
};
}
};
delayService.setOnSucceeded(_ -> {
delayService.cancel();
runnable.run();
});
delayService.setDelay(Duration.millis(delay));
delayService.start();
}
}
private static Image getWindowIcon() { private static Image getWindowIcon() {
if(windowIcon == null) { if(windowIcon == null) {
windowIcon = new Image(SparrowWallet.class.getResourceAsStream("/image/sparrow-icon.png")); windowIcon = new Image(SparrowWallet.class.getResourceAsStream("/image/sparrow-icon.png"));
@ -574,8 +642,17 @@ public class AppServices {
return windowIcon; return windowIcon;
} }
public static boolean isReducedWindowHeight() {
Window activeWindow = getActiveWindow();
return (activeWindow != null && activeWindow.getHeight() < getReducedWindowHeight());
}
public static boolean isReducedWindowHeight(Node node) { public static boolean isReducedWindowHeight(Node node) {
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768); return (node.getScene() != null && node.getScene().getWindow().getHeight() < getReducedWindowHeight());
}
private static double getReducedWindowHeight() {
return OsType.getCurrent() != OsType.MACOS ? 802d : 768d; //Check for menu bar of ~34px
} }
public Application getApplication() { public Application getApplication() {
@ -660,17 +737,49 @@ public class AppServices {
return latestBlockHeader; return latestBlockHeader;
} }
public static Map<Integer, BlockSummary> getBlockSummaries() {
return blockSummaries;
}
public static Double getDefaultFeeRate() { public static Double getDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget); return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget);
} }
public static Double getMinimumFeeRate() { public static Double getMinimumFeeRate() {
Optional<Double> optMinFeeRate = getTargetBlockFeeRates().values().stream().min(Double::compareTo); Optional<Double> optMinFeeRate = getTargetBlockFeeRates().values().stream().min(Double::compareTo);
Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE); Double minRate = optMinFeeRate.orElse(getFallbackFeeRate());
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE); return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
} }
public static List<Double> getLongFeeRatesRange() {
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return LONG_FEE_RATES_RANGE;
} else {
List<Double> longFeeRatesRange = new ArrayList<>();
longFeeRatesRange.add(minimumRelayFeeRate);
longFeeRatesRange.addAll(LONG_FEE_RATES_RANGE);
return longFeeRatesRange;
}
}
public static List<Double> getFeeRatesRange() {
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
} else {
List<Double> longFeeRatesRange = getLongFeeRatesRange();
return longFeeRatesRange.subList(0, longFeeRatesRange.size() - 4);
}
}
public static Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate;
}
public static double getFallbackFeeRate() {
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
}
public static Map<Integer, Double> getTargetBlockFeeRates() { public static Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates; return targetBlockFeeRates;
} }
@ -680,6 +789,10 @@ public class AppServices {
} }
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) { private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
if(rateSizes.isEmpty()) {
return;
}
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
if(mempoolHistogram.isEmpty()) { if(mempoolHistogram.isEmpty()) {
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes); mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
@ -689,12 +802,26 @@ public class AppServices {
Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant()); Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
mempoolHistogram.keySet().removeIf(date -> date.before(yesterday)); mempoolHistogram.keySet().removeIf(date -> date.before(yesterday));
ZonedDateTime twoHoursAgo = LocalDateTime.now().minusHours(2).atZone(ZoneId.systemDefault());
mempoolHistogram.keySet().removeIf(date -> {
ZonedDateTime dateTime = date.toInstant().atZone(ZoneId.systemDefault());
return dateTime.isBefore(twoHoursAgo) && (dateTime.getMinute() % 10 != 0);
});
}
public static Double getConfiguredMinimumRelayFeeRate(Config config) {
return config.getMinRelayFeeRate() >= 0d && config.getMinRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE ? config.getMinRelayFeeRate() : null;
} }
public static Double getMinimumRelayFeeRate() { public static Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate; return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
} }
public static Double getServerMinimumRelayFeeRate() {
return serverMinimumRelayFeeRate;
}
public static CurrencyRate getFiatCurrencyExchangeRate() { public static CurrencyRate getFiatCurrencyExchangeRate() {
return fiatCurrencyExchangeRate; return fiatCurrencyExchangeRate;
} }
@ -708,8 +835,8 @@ public class AppServices {
} }
public static void addPayjoinURI(BitcoinURI bitcoinURI) { public static void addPayjoinURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getPayjoinUrl() == null) { if(bitcoinURI.getPayjoinUrl() == null || bitcoinURI.getAddress() == null) {
throw new IllegalArgumentException("Not a payjoin URI"); throw new IllegalArgumentException("Not a valid payjoin URI");
} }
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI); payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
} }
@ -766,7 +893,7 @@ public class AppServices {
} }
public static Window getActiveWindow() { public static Window getActiveWindow() {
return Stage.getWindows().stream().filter(Window::isFocused).findFirst().orElse(get().walletWindows.keySet().iterator().hasNext() ? get().walletWindows.keySet().iterator().next() : null); return Stage.getWindows().stream().filter(Window::isFocused).findFirst().orElse(get().walletWindows.keySet().iterator().hasNext() ? get().walletWindows.keySet().iterator().next() : (Stage.getWindows().iterator().hasNext() ? Stage.getWindows().iterator().next() : null));
} }
public static void moveToActiveWindowScreen(Dialog<?> dialog) { public static void moveToActiveWindowScreen(Dialog<?> dialog) {
@ -803,6 +930,24 @@ public class AppServices {
} }
} }
public static void openBlockExplorer(String txid) {
if(Config.get().isBlockExplorerDisabled()) {
return;
}
Server blockExplorer = Config.get().getBlockExplorer() == null ? BlockExplorer.MEMPOOL_SPACE.getServer() : Config.get().getBlockExplorer();
String url = blockExplorer.getUrl();
if(url.contains("{0}")) {
url = url.replace("{0}", txid);
} else {
if(Network.get() != Network.MAINNET) {
url += "/" + Network.get().getName();
}
url += "/tx/" + txid;
}
AppServices.get().getApplication().getHostServices().showDocument(url);
}
static void parseFileUriArguments(List<String> fileUriArguments) { static void parseFileUriArguments(List<String> fileUriArguments) {
for(String fileUri : fileUriArguments) { for(String fileUri : fileUriArguments) {
try { try {
@ -821,6 +966,25 @@ public class AppServices {
} }
} }
public static void openFileUriArgumentsAfterWalletLoading(Window window) {
if(!argFiles.isEmpty() || !argUris.isEmpty()) {
Service<Void> service = new Service<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
Platform.runLater(() -> openFileUriArguments(window));
return null;
}
};
}
};
service.setExecutor(Storage.LoadWalletService.getSingleThreadedExecutor());
service.start();
}
}
public static void openFileUriArguments(Window window) { public static void openFileUriArguments(Window window) {
openFiles(argFiles, window); openFiles(argFiles, window);
argFiles.clear(); argFiles.clear();
@ -840,6 +1004,7 @@ public class AppServices {
} }
if(openWindow instanceof Stage) { if(openWindow instanceof Stage) {
((Stage)openWindow).setIconified(false);
((Stage)openWindow).setAlwaysOnTop(true); ((Stage)openWindow).setAlwaysOnTop(true);
((Stage)openWindow).setAlwaysOnTop(false); ((Stage)openWindow).setAlwaysOnTop(false);
} }
@ -847,6 +1012,8 @@ public class AppServices {
for(File file : openFiles) { for(File file : openFiles) {
if(isWalletFile(file)) { if(isWalletFile(file)) {
EventManager.get().post(new RequestWalletOpenEvent(openWindow, file)); EventManager.get().post(new RequestWalletOpenEvent(openWindow, file));
} else if(isVerifyDownloadFile(file)) {
EventManager.get().post(new RequestVerifyDownloadEvent(openWindow, file));
} else { } else {
EventManager.get().post(new RequestTransactionOpenEvent(openWindow, file)); EventManager.get().post(new RequestTransactionOpenEvent(openWindow, file));
} }
@ -890,7 +1057,7 @@ public class AppServices {
if(wallet != null) { if(wallet != null) {
final Wallet sendingWallet = wallet; final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true)); EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getSpendableUtxos().keySet()), true));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment())))); Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
} }
} catch(Exception e) { } catch(Exception e) {
@ -930,6 +1097,7 @@ public class AppServices {
Storage storage = AppServices.get().getOpenWallets().get(wallet); Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet copy = wallet.copy(); Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(getActiveWindow());
Optional<SecureString> password = dlg.showAndWait(); Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) { if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true); Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
@ -992,10 +1160,10 @@ public class AppServices {
wallet = wallets.iterator().next(); wallet = wallets.iterator().next();
} else { } else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets); ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
walletChoiceDialog.initOwner(getActiveWindow());
walletChoiceDialog.setTitle("Choose Wallet"); walletChoiceDialog.setTitle("Choose Wallet");
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription); walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
Image image = new Image("/image/sparrow-small.png"); walletChoiceDialog.getDialogPane().setGraphic(new DialogImage(DialogImage.Type.SPARROW));
walletChoiceDialog.getDialogPane().setGraphic(new ImageView(image));
setStageIcon(walletChoiceDialog.getDialogPane().getScene().getWindow()); setStageIcon(walletChoiceDialog.getDialogPane().getScene().getWindow());
moveToActiveWindowScreen(walletChoiceDialog); moveToActiveWindowScreen(walletChoiceDialog);
Optional<Wallet> optWallet = walletChoiceDialog.showAndWait(); Optional<Wallet> optWallet = walletChoiceDialog.showAndWait();
@ -1007,19 +1175,96 @@ public class AppServices {
return wallet; return wallet;
} }
public static boolean disallowAnyInvalidDerivationPaths(Wallet wallet) {
Optional<ScriptType> optInvalidScriptType = wallet.getKeystores().stream()
.filter(keystore -> keystore.getKeyDerivation() != null)
.map(keystore -> wallet.getOtherScriptTypeMatchingDerivation(keystore.getKeyDerivation().getDerivationPath()))
.filter(Optional::isPresent).map(Optional::get).findFirst();
if(optInvalidScriptType.isPresent()) {
ScriptType invalidScriptType = optInvalidScriptType.get();
boolean includePolicyType = !wallet.getScriptType().getAllowedPolicyTypes().getFirst().equals(invalidScriptType.getAllowedPolicyTypes().getFirst());
Optional<ButtonType> optType = AppServices.showWarningDialog("Invalid derivation path", "This wallet is using the derivation path for " +
invalidScriptType.getDescription(includePolicyType) + ", instead of the derivation path for its defined script type of " + wallet.getScriptType().getDescription(includePolicyType) +
". \n\nDisable derivation path validation to import this wallet?", ButtonType.NO, ButtonType.YES);
if(optType.isPresent()) {
if(optType.get() == ButtonType.YES) {
Config.get().setValidateDerivationPaths(false);
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(true));
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, Boolean.toString(true));
} else {
return true;
}
}
}
return false;
}
public static final List<Network> WHIRLPOOL_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
public static boolean isWhirlpoolCompatible(Wallet wallet) {
return WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().get(0).hasSeed()
&& wallet.getKeystores().get(0).getSeed().getType() == DeterministicSeed.Type.BIP39
&& wallet.getStandardAccountType() != null
&& StandardAccount.isMixableAccount(wallet.getStandardAccountType());
}
public static boolean isWhirlpoolPostmixCompatible(Wallet wallet) {
return WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().getFirst().getWalletModel() != WalletModel.BITBOX_02; //BitBox02 does not support high account numbers
}
public static List<Wallet> addWhirlpoolWallets(Wallet decryptedWallet, String walletId, Storage storage) {
List<Wallet> childWallets = new ArrayList<>();
for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) {
if(decryptedWallet.getChildWallet(whirlpoolAccount) == null) {
Wallet childWallet = decryptedWallet.addChildWallet(whirlpoolAccount);
childWallets.add(childWallet);
EventManager.get().post(new ChildWalletsAddedEvent(storage, decryptedWallet, childWallet));
}
}
return childWallets;
}
public static Font getMonospaceFont() { public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13); return Font.font("Fragment Mono Regular", 13);
}
public static boolean isOnWayland() {
if(OsType.getCurrent() != OsType.UNIX) {
return false;
}
String waylandDisplay = System.getenv("WAYLAND_DISPLAY");
return waylandDisplay != null && !waylandDisplay.isEmpty();
} }
@Subscribe @Subscribe
public void newConnection(ConnectionEvent event) { public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight(); currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight)); System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates(); if(getConfiguredMinimumRelayFeeRate(Config.get()) == null) {
addMempoolRateSizes(event.getMempoolRateSizes()); minimumRelayFeeRate = event.getMinimumRelayFeeRate() == null ? Transaction.DEFAULT_MIN_RELAY_FEE : event.getMinimumRelayFeeRate();
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE); }
serverMinimumRelayFeeRate = event.getMinimumRelayFeeRate();
latestBlockHeader = event.getBlockHeader(); latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer(); Config.get().addRecentServer();
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
fetchFeeRates();
}
if(!blockSummaries.containsKey(currentBlockHeight)) {
fetchBlockSummaries(Collections.emptyList());
}
} }
@Subscribe @Subscribe
@ -1034,22 +1279,36 @@ public class AppServices {
latestBlockHeader = event.getBlockHeader(); latestBlockHeader = event.getBlockHeader();
String status = "Updating to new block height " + event.getHeight(); String status = "Updating to new block height " + event.getHeight();
EventManager.get().post(new StatusEvent(status)); EventManager.get().post(new StatusEvent(status));
newBlockSubject.onNext(event);
}
@Subscribe
public void blockSummary(BlockSummaryEvent event) {
blockSummaries.putAll(event.getBlockSummaryMap());
if(AppServices.currentBlockHeight != null) {
blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5);
}
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
} }
@Subscribe @Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) { public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates(); targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes()); nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
}
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
if(event.getMempoolRateSizes() != null) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
} }
@Subscribe @Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) { public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService();
feeRatesService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(feeRatesService.getValue());
});
//Perform once-off fee rates retrieval to immediately change displayed rates //Perform once-off fee rates retrieval to immediately change displayed rates
feeRatesService.start(); fetchFeeRates();
fetchBlockSummaries(Collections.emptyList());
} }
@Subscribe @Subscribe
@ -1104,7 +1363,7 @@ public class AppServices {
Wallet wallet = walletTabData.getWallet(); Wallet wallet = walletTabData.getWallet();
Storage storage = walletTabData.getStorage(); Storage storage = walletTabData.getStorage();
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB))) { if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB) || CardApi.isReaderAvailable())) {
usbWallet = true; usbWallet = true;
if(deviceEnumerateService == null) { if(deviceEnumerateService == null) {

View file

@ -12,7 +12,12 @@ import org.fxmisc.richtext.event.MouseOverTextEvent;
import org.fxmisc.richtext.model.TwoDimensional; import org.fxmisc.richtext.model.TwoDimensional;
import java.time.Duration; import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static com.sparrowwallet.drongo.protocol.ScriptType.*;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward; import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
public abstract class BaseController { public abstract class BaseController {
@ -24,14 +29,11 @@ public abstract class BaseController {
scriptArea.setMouseOverTextDelay(Duration.ofMillis(150)); scriptArea.setMouseOverTextDelay(Duration.ofMillis(150));
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> { scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
TwoDimensional.Position position = scriptArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward); ScriptChunk hoverChunk = getScriptChunk(scriptArea, e.getCharacterIndex());
if(position.getMajor() % 2 == 0) { if(hoverChunk != null) {
ScriptChunk hoverChunk = scriptArea.getScript().getChunks().get(position.getMajor()/2); Point2D pos = e.getScreenPosition();
if(!hoverChunk.isOpCode()) { popupMsg.setText(describeScriptChunk(hoverChunk));
Point2D pos = e.getScreenPosition(); popup.show(scriptArea, pos.getX(), pos.getY() + 10);
popupMsg.setText(describeScriptChunk(hoverChunk));
popup.show(scriptArea, pos.getX(), pos.getY() + 10);
}
} }
}); });
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> { scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
@ -80,4 +82,26 @@ public abstract class BaseController {
return "Invalid"; return "Invalid";
} }
public static ScriptChunk getScriptChunk(ScriptArea area, int characterIndex) {
TwoDimensional.Position position = area.getParagraph(0).getStyleSpans().offsetToPosition(characterIndex, Backward);
int ignoreCount = 0;
for(int i = 0; i < position.getMajor() && i < area.getParagraph(0).getStyleSpans().getSpanCount(); i++) {
Collection<String> styles = area.getParagraph(0).getStyleSpans().getStyleSpan(i).getStyle();
if(i < position.getMajor() && (styles.contains("") || styles.contains("script-nest"))) {
ignoreCount++;
}
}
boolean hashScripts = List.of(P2PKH, P2SH, P2WPKH, P2WSH).stream().anyMatch(type -> type.isScriptType(area.getScript()));
List<ScriptChunk> flatChunks = area.getScript().getChunks().stream().flatMap(chunk -> !hashScripts && chunk.isScript() ? chunk.getScript().getChunks().stream() : Stream.of(chunk)).collect(Collectors.toList());
int chunkIndex = position.getMajor() - ignoreCount;
if(chunkIndex < flatChunks.size()) {
ScriptChunk chunk = flatChunks.get(chunkIndex);
if(!chunk.isOpCode()) {
return chunk;
}
}
return null;
}
} }

View file

@ -0,0 +1,76 @@
package com.sparrowwallet.sparrow;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;
public class BlockSummary implements Comparable<BlockSummary> {
private final Integer height;
private final Date timestamp;
private final Double medianFee;
private final Integer transactionCount;
private final Integer weight;
public BlockSummary(Integer height, Date timestamp) {
this(height, timestamp, null, null, null);
}
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount, Integer weight) {
this.height = height;
this.timestamp = timestamp;
this.medianFee = medianFee;
this.transactionCount = transactionCount;
this.weight = weight;
}
public Integer getHeight() {
return height;
}
public Date getTimestamp() {
return timestamp;
}
public Optional<Double> getMedianFee() {
return medianFee == null ? Optional.empty() : Optional.of(medianFee);
}
public Optional<Integer> getTransactionCount() {
return transactionCount == null ? Optional.empty() : Optional.of(transactionCount);
}
public Optional<Integer> getWeight() {
return weight == null ? Optional.empty() : Optional.of(weight);
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public String getElapsed() {
long elapsed = calculateElapsedSeconds(getTimestamp().getTime());
if(elapsed < 0) {
return "now";
} else if(elapsed < 60) {
return elapsed + "s";
} else if(elapsed < 3600) {
return elapsed / 60 + "m";
} else if(elapsed < 86400) {
return elapsed / 3600 + "h";
} else {
return elapsed / 86400 + "d";
}
}
public String toString() {
return getElapsed() + ":" + getMedianFee();
}
@Override
public int compareTo(BlockSummary o) {
return o.height - height;
}
}

View file

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow; package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.control.KeystorePassphraseDialog; import com.sparrowwallet.sparrow.control.KeystorePassphraseDialog;
import com.sparrowwallet.sparrow.control.TextUtils; import com.sparrowwallet.sparrow.control.TextUtils;
@ -15,13 +16,13 @@ import java.util.Optional;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen; import static com.sparrowwallet.sparrow.AppServices.*;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
public class DefaultInteractionServices implements InteractionServices { public class DefaultInteractionServices implements InteractionServices {
@Override @Override
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) { public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons); Alert alert = new Alert(alertType, content, buttons);
alert.initOwner(getActiveWindow());
setStageIcon(alert.getDialogPane().getScene().getWindow()); setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title); alert.setTitle(title);
@ -48,11 +49,13 @@ public class DefaultInteractionServices implements InteractionServices {
} }
String[] lines = content.split("\r\n|\r|\n"); String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) { if(lines.length > 3 || OsType.getCurrent() == OsType.WINDOWS) {
double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum(); double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum();
alert.getDialogPane().setPrefHeight(200 + numLines * 20); alert.getDialogPane().setPrefHeight(200 + numLines * 20);
} }
alert.setResizable(true);
moveToActiveWindowScreen(alert); moveToActiveWindowScreen(alert);
return alert.showAndWait(); return alert.showAndWait();
} }
@ -60,6 +63,7 @@ public class DefaultInteractionServices implements InteractionServices {
@Override @Override
public Optional<String> requestPassphrase(String walletName, Keystore keystore) { public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore); KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
passphraseDialog.initOwner(getActiveWindow());
return passphraseDialog.showAndWait(); return passphraseDialog.showAndWait();
} }
} }

View file

@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow; package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.WalletIcon; import com.sparrowwallet.sparrow.control.WalletIcon;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@ -9,13 +10,12 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.PublicElectrumServer; import com.sparrowwallet.sparrow.net.PublicElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType; import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup; import com.sparrowwallet.sparrow.settings.SettingsGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog; import com.sparrowwallet.sparrow.settings.SettingsDialog;
import javafx.application.Application; import javafx.application.Application;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.controlsfx.glyphfont.GlyphFontRegistry; import org.controlsfx.glyphfont.GlyphFontRegistry;
import org.controlsfx.tools.Platform;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
@ -42,9 +42,7 @@ public class SparrowDesktop extends Application {
public void start(Stage stage) throws Exception { public void start(Stage stage) throws Exception {
this.mainStage = stage; this.mainStage = stage;
GlyphFontRegistry.register(new FontAwesome5()); initializeFonts();
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null); URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
AppServices.initialize(this); AppServices.initialize(this);
@ -59,8 +57,8 @@ public class SparrowDesktop extends Application {
Config.get().setMode(mode); Config.get().setMode(mode);
if(mode.equals(Mode.ONLINE)) { if(mode.equals(Mode.ONLINE)) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER, true); SettingsDialog settingsDialog = new SettingsDialog(SettingsGroup.SERVER, true);
Optional<Boolean> optNewWallet = preferencesDialog.showAndWait(); Optional<Boolean> optNewWallet = settingsDialog.showAndWait();
createNewWallet = optNewWallet.isPresent() && optNewWallet.get(); createNewWallet = optNewWallet.isPresent() && optNewWallet.get();
} else if(Network.get() == Network.MAINNET) { } else if(Network.get() == Network.MAINNET) {
Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER); Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
@ -74,11 +72,8 @@ public class SparrowDesktop extends Application {
Config.get().setServerType(ServerType.ELECTRUM_SERVER); Config.get().setServerType(ServerType.ELECTRUM_SERVER);
} }
if(Config.get().getHdCapture() == null && Platform.getCurrent() == Platform.OSX) {
Config.get().setHdCapture(Boolean.TRUE);
}
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths())); System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
if(Config.get().getAppHeight() != null && Config.get().getAppWidth() != null) { if(Config.get().getAppHeight() != null && Config.get().getAppWidth() != null) {
mainStage.setWidth(Config.get().getAppWidth()); mainStage.setWidth(Config.get().getAppWidth());
@ -87,28 +82,42 @@ public class SparrowDesktop extends Application {
AppController appController = AppServices.newAppWindow(stage); AppController appController = AppServices.newAppWindow(stage);
if(createNewWallet) { final boolean showNewWallet = createNewWallet;
appController.newWallet(null); //Delay opening new dialogs on Wayland
} AppServices.runAfterDelay(AppServices.isOnWayland() ? 1000 : 0, () -> {
if(showNewWallet) {
appController.newWallet(null);
}
List<File> recentWalletFiles = Config.get().getRecentWalletFiles(); List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
if(recentWalletFiles != null) { if(recentWalletFiles != null) {
//Preserve wallet order as far as possible. Unencrypted wallets will still be opened first. //Preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(Storage::isEncrypted).collect(Collectors.toList()); List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(Storage::isEncrypted).collect(Collectors.toList());
List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles); List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles);
sortedWalletFiles.removeAll(encryptedWalletFiles); sortedWalletFiles.removeAll(encryptedWalletFiles);
sortedWalletFiles.addAll(encryptedWalletFiles); sortedWalletFiles.addAll(encryptedWalletFiles);
for(File walletFile : sortedWalletFiles) { for(File walletFile : sortedWalletFiles) {
if(walletFile.exists()) { if(walletFile.exists()) {
appController.openWalletFile(walletFile, false); appController.openWalletFile(walletFile, false);
}
} }
} }
AppServices.openFileUriArgumentsAfterWalletLoading(stage);
AppServices.get().start();
});
}
private void initializeFonts() {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Italic.ttf"), 11);
if(OsType.getCurrent() == OsType.MACOS) {
Font.loadFont(AppServices.class.getResourceAsStream("/font/LiberationSans-Regular.ttf"), 13);
} }
AppServices.openFileUriArguments(stage);
AppServices.get().start();
} }
@Override @Override

View file

@ -16,9 +16,9 @@ import java.io.File;
import java.util.*; import java.util.*;
public class SparrowWallet { public class SparrowWallet {
public static final String APP_ID = "com.sparrowwallet.sparrow"; public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow"; public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.7.1"; public static final String APP_VERSION = "2.3.1";
public static final String APP_VERSION_SUFFIX = ""; public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home"; public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK"; public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@ -66,6 +66,11 @@ public class SparrowWallet {
Network.set(Network.TESTNET); Network.set(Network.TESTNET);
} }
File testnet4Flag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET4.getName());
if(testnet4Flag.exists()) {
Network.set(Network.TESTNET4);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName()); File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) { if(signetFlag.exists()) {
Network.set(Network.SIGNET); Network.set(Network.SIGNET);
@ -79,7 +84,7 @@ public class SparrowWallet {
try { try {
instance = new Instance(fileUriArguments); instance = new Instance(fileUriArguments);
instance.acquireLock(); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired instance.acquireLock(!fileUriArguments.isEmpty()); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired
} catch(InstanceException e) { } catch(InstanceException e) {
getLogger().error("Could not access application lock", e); getLogger().error("Could not access application lock", e);
} }
@ -130,13 +135,13 @@ public class SparrowWallet {
private final List<String> fileUriArguments; private final List<String> fileUriArguments;
public Instance(List<String> fileUriArguments) { public Instance(List<String> fileUriArguments) {
super(SparrowWallet.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty()); super(SparrowWallet.APP_ID, true);
this.fileUriArguments = fileUriArguments; this.fileUriArguments = fileUriArguments;
} }
@Override @Override
protected void receiveMessageList(List<String> messageList) { protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) { if(messageList != null) {
AppServices.parseFileUriArguments(messageList); AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null); AppServices.openFileUriArguments(null);
} }

View file

@ -1,17 +0,0 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.LogHandler;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
public class TorLogHandler implements LogHandler {
private static final Logger log = LoggerFactory.getLogger(TorLogHandler.class);
@Override
public void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData) {
log.debug(message);
EventManager.get().post(new TorStatusEvent(message));
}
}

View file

@ -12,6 +12,7 @@ public enum UnitFormat {
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols()); private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols()); private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols()); private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() { public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8); btcFormat.setMaximumFractionDigits(8);
@ -30,6 +31,10 @@ public enum UnitFormat {
return currencyFormat; return currencyFormat;
} }
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() { public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols(); DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator('.'); symbols.setDecimalSeparator('.');
@ -42,6 +47,7 @@ public enum UnitFormat {
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols()); private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols()); private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols()); private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() { public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8); btcFormat.setMaximumFractionDigits(8);
@ -60,6 +66,10 @@ public enum UnitFormat {
return currencyFormat; return currencyFormat;
} }
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() { public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols(); DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator(','); symbols.setDecimalSeparator(',');
@ -78,10 +88,16 @@ public enum UnitFormat {
public abstract DecimalFormat getCurrencyFormat(); public abstract DecimalFormat getCurrencyFormat();
public abstract DecimalFormat getTableCurrencyFormat();
public String formatBtcValue(Long amount) { public String formatBtcValue(Long amount) {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN); return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
} }
public String tableFormatBtcValue(Long amount) {
return getTableBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String formatSatsValue(Long amount) { public String formatSatsValue(Long amount) {
return getSatsFormat().format(amount); return getSatsFormat().format(amount);
} }
@ -90,6 +106,10 @@ public enum UnitFormat {
return getCurrencyFormat().format(amount); return getCurrencyFormat().format(amount);
} }
public String tableFormatCurrencyValue(double amount) {
return getTableCurrencyFormat().format(amount);
}
public String getGroupingSeparator() { public String getGroupingSeparator() {
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator()); return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
} }

View file

@ -21,7 +21,8 @@ public class WelcomeDialog extends Dialog<Mode> {
welcomeController.initializeView(); welcomeController.initializeView();
dialogPane.setPrefWidth(600); dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520); dialogPane.setPrefHeight(540);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this); AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());

View file

@ -5,19 +5,19 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.util.StringConverter; import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList; import java.util.*;
import java.util.List;
import static com.sparrowwallet.drongo.wallet.StandardAccount.*;
public class AddAccountDialog extends Dialog<List<StandardAccount>> { public class AddAccountDialog extends Dialog<List<StandardAccount>> {
private static final int MAX_SHOWN_ACCOUNTS = 8;
private final ComboBox<StandardAccount> standardAccountCombo; private final ComboBox<StandardAccount> standardAccountCombo;
private boolean discoverAccounts = false; private boolean discoverAccounts = false;
@ -42,29 +42,32 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
standardAccountCombo = new ComboBox<>(); standardAccountCombo = new ComboBox<>();
standardAccountCombo.setMaxWidth(Double.MAX_VALUE); standardAccountCombo.setMaxWidth(Double.MAX_VALUE);
List<Integer> existingIndexes = new ArrayList<>(); Set<Integer> existingIndexes = new LinkedHashSet<>();
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
existingIndexes.add(masterWallet.getAccountIndex()); existingIndexes.add(masterWallet.getAccountIndex());
for(Wallet childWallet : masterWallet.getChildWallets()) { for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isNested()) { if(!childWallet.isNested()) {
existingIndexes.add(childWallet.getAccountIndex()); existingIndexes.add(childWallet.getAccountIndex());
Optional<StandardAccount> optStdAcc = Arrays.stream(StandardAccount.values()).filter(stdacc -> stdacc.getName().equals(childWallet.getName())).findFirst();
optStdAcc.ifPresent(standardAccount -> existingIndexes.add(standardAccount.getAccountNumber()));
} }
} }
List<StandardAccount> availableAccounts = new ArrayList<>(); List<StandardAccount> availableAccounts = new ArrayList<>();
for(StandardAccount standardAccount : StandardAccount.values()) { for(StandardAccount standardAccount : StandardAccount.values()) {
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) { if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.isWhirlpoolAccount(standardAccount) && availableAccounts.size() <= MAX_SHOWN_ACCOUNTS) {
availableAccounts.add(standardAccount); availableAccounts.add(standardAccount);
} }
} }
if(WhirlpoolServices.canWalletMix(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) { if(AppServices.isWhirlpoolCompatible(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
availableAccounts.add(StandardAccount.WHIRLPOOL_PREMIX); availableAccounts.add(WHIRLPOOL_PREMIX);
} else if(AppServices.isWhirlpoolPostmixCompatible(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
availableAccounts.add(WHIRLPOOL_POSTMIX);
} }
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT); final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
if(!availableAccounts.isEmpty() && Config.get().getServerType() != ServerType.BITCOIN_CORE && if(!availableAccounts.isEmpty() && (masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
|| (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) { || (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) {
dialogPane.getButtonTypes().add(discoverButtonType); dialogPane.getButtonTypes().add(discoverButtonType);
Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType); Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType);
@ -82,10 +85,14 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
return "None Available"; return "None Available";
} }
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(account)) { if(account == WHIRLPOOL_PREMIX) {
return "Whirlpool Accounts"; return "Whirlpool Accounts";
} }
if(account == WHIRLPOOL_POSTMIX) {
return "Whirlpool Postmix (No mixing)";
}
return account.getName(); return account.getName();
} }

View file

@ -40,11 +40,12 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
if(utxoEntry != null) { if(utxoEntry != null) {
Address address = addressStatus.getAddress(); Address address = addressStatus.getAddress();
setText(address.toString()); setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false)); setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false, getTreeTableView()));
Tooltip tooltip = new Tooltip(); Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250)); tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack())); tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
setTooltip(tooltip); setTooltip(tooltip);
getStyleClass().add("address-cell");
if(addressStatus.isDustAttack()) { if(addressStatus.isDustAttack()) {
setGraphic(getDustAttackHyperlink(utxoEntry)); setGraphic(getDustAttackHyperlink(utxoEntry));

View file

@ -77,7 +77,7 @@ public class AddressTreeTable extends CoinTreeTable {
getColumns().forEach(col -> col.setContextMenu(contextMenu)); getColumns().forEach(col -> col.setContextMenu(contextMenu));
setEditable(true); setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY); setupColumnWidths();
addressCol.setSortType(TreeTableColumn.SortType.ASCENDING); addressCol.setSortType(TreeTableColumn.SortType.ASCENDING);
getSortOrder().add(addressCol); getSortOrder().add(addressCol);

View file

@ -0,0 +1,33 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import static com.sparrowwallet.sparrow.AppServices.*;
public class BitBoxPairingDialog extends Alert {
public BitBoxPairingDialog(String code) {
super(AlertType.INFORMATION);
initOwner(getActiveWindow());
setStageIcon(getDialogPane().getScene().getWindow());
getDialogPane().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
setTitle("Confirm BitBox02 Pairing");
setHeaderText(getTitle());
VBox vBox = new VBox(20);
vBox.setAlignment(Pos.CENTER);
vBox.setPadding(new Insets(10, 20, 10, 20));
Label instructions = new Label("Confirm the following code is shown on BitBox02");
Label codeLabel = new Label(code);
codeLabel.getStyleClass().add("fixed-width");
vBox.getChildren().addAll(instructions, codeLabel);
getDialogPane().setContent(vBox);
moveToActiveWindowScreen(this);
getDialogPane().getButtonTypes().clear();
getDialogPane().getButtonTypes().add(ButtonType.CLOSE);
}
}

View file

@ -0,0 +1,372 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.BlockSummary;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.scene.Group;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.girod.javafx.svgimage.SVGImage;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
public class BlockCube extends Group {
public static final List<Integer> MEMPOOL_FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000);
public static final double CUBE_SIZE = 60;
private final IntegerProperty weightProperty = new SimpleIntegerProperty(0);
private final DoubleProperty medianFeeProperty = new SimpleDoubleProperty(-Double.MAX_VALUE);
private final IntegerProperty heightProperty = new SimpleIntegerProperty(0);
private final IntegerProperty txCountProperty = new SimpleIntegerProperty(0);
private final LongProperty timestampProperty = new SimpleLongProperty(System.currentTimeMillis());
private final StringProperty elapsedProperty = new SimpleStringProperty("");
private final BooleanProperty confirmedProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<FeeRatesSource> feeRatesSource = new SimpleObjectProperty<>(null);
private Polygon front;
private Rectangle unusedArea;
private Rectangle usedArea;
private final Text heightText = new Text();
private final Text medianFeeText = new Text();
private final Text unitsText = new Text();
private final TextFlow medianFeeTextFlow = new TextFlow();
private final Text txCountText = new Text();
private final Text elapsedText = new Text();
private final Group feeRateIcon = new Group();
public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) {
getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube");
this.confirmedProperty.set(confirmed);
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
this.feeRatesSource.set(feeRatesSource);
this.weightProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeProperty.addListener((_, _, newValue) -> {
medianFeeText.setText(newValue.doubleValue() < 0.0d ? "" : "~" + Math.round(Math.max(newValue.doubleValue(), 1.0d)));
unitsText.setText(newValue.doubleValue() < 0.0d ? "" : " s/vb");
double medianFeeWidth = TextUtils.computeTextWidth(medianFeeText.getFont(), medianFeeText.getText(), 0.0d);
double unitsWidth = TextUtils.computeTextWidth(unitsText.getFont(), unitsText.getText(), 0.0d);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeWidth + unitsWidth)) / 2);
});
this.txCountProperty.addListener((_, _, newValue) -> {
txCountText.setText(newValue.intValue() == 0 ? "" : newValue + " txes");
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
});
this.timestampProperty.addListener((_, _, newValue) -> {
elapsedProperty.set(getElapsed(newValue.longValue()));
});
this.elapsedProperty.addListener((_, _, newValue) -> {
elapsedText.setText(isConfirmed() ? newValue : "In ~10m");
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
});
this.heightProperty.addListener((_, _, newValue) -> {
heightText.setText(newValue.intValue() == 0 ? "" : String.valueOf(newValue));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
});
this.confirmedProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.feeRatesSource.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeText.textProperty().addListener((_, _, _) -> {
pulse();
});
if(weight != null) {
this.weightProperty.set(weight);
}
if(medianFee != null) {
this.medianFeeProperty.set(medianFee);
}
if(height != null) {
this.heightProperty.set(height);
}
if(txCount != null) {
this.txCountProperty.set(txCount);
}
if(timestamp != null) {
this.timestampProperty.set(timestamp);
}
drawCube();
}
private void drawCube() {
double depth = CUBE_SIZE * 0.2;
double perspective = CUBE_SIZE * 0.04;
front = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE, CUBE_SIZE, 0, CUBE_SIZE);
front.getStyleClass().add("block-front");
front.setFill(null);
unusedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
unusedArea.getStyleClass().add("block-unused");
usedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
usedArea.getStyleClass().add("block-used");
Group frontFaceGroup = new Group(front, unusedArea, usedArea);
Polygon top = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE - depth - perspective, -depth, -depth, -depth);
top.getStyleClass().add("block-top");
top.setStroke(null);
Polygon left = new Polygon(0, 0, -depth, -depth, -depth, CUBE_SIZE - depth - perspective, 0, CUBE_SIZE);
left.getStyleClass().add("block-left");
left.setStroke(null);
updateFill();
heightText.getStyleClass().add("block-height");
heightText.setFont(new Font(11));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
heightText.setY(-24);
medianFeeText.getStyleClass().add("block-text");
medianFeeText.setFont(Font.font(null, FontWeight.BOLD, 11));
unitsText.getStyleClass().add("block-text");
unitsText.setFont(new Font(10));
medianFeeTextFlow.getChildren().addAll(medianFeeText, unitsText);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeText.getLayoutBounds().getWidth() + unitsText.getLayoutBounds().getWidth())) / 2);
medianFeeTextFlow.setTranslateY(7);
txCountText.getStyleClass().add("block-text");
txCountText.setFont(new Font(10));
txCountText.setOpacity(0.7);
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
txCountText.setY(34);
feeRateIcon.setTranslateX(((CUBE_SIZE * 0.7) - 14) / 2);
feeRateIcon.setTranslateY(-36);
elapsedText.getStyleClass().add("block-text");
elapsedText.setFont(new Font(10));
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
elapsedText.setY(50);
getChildren().addAll(frontFaceGroup, top, left, heightText, medianFeeTextFlow, txCountText, feeRateIcon, elapsedText);
}
private void updateFill() {
if(isConfirmed()) {
getStyleClass().removeAll("block-unconfirmed");
if(!getStyleClass().contains("block-confirmed")) {
getStyleClass().add("block-confirmed");
}
double startY = 1 - weightProperty.doubleValue() / (Transaction.MAX_BLOCK_SIZE_VBYTES * Transaction.WITNESS_SCALE_FACTOR);
double startYAbsolute = startY * BlockCube.CUBE_SIZE;
unusedArea.setHeight(startYAbsolute);
unusedArea.setStyle(null);
usedArea.setY(startYAbsolute);
usedArea.setHeight(CUBE_SIZE - startYAbsolute);
usedArea.setVisible(true);
heightText.setVisible(true);
feeRateIcon.getChildren().clear();
} else {
getStyleClass().removeAll("block-confirmed");
if(!getStyleClass().contains("block-unconfirmed")) {
getStyleClass().add("block-unconfirmed");
}
usedArea.setVisible(false);
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
heightText.setVisible(false);
if(feeRatesSource.get() != null) {
SVGImage svgImage = feeRatesSource.get().getSVGImage();
if(svgImage != null) {
feeRateIcon.getChildren().setAll(feeRatesSource.get().getSVGImage());
} else {
feeRateIcon.getChildren().clear();
}
} else {
feeRateIcon.getChildren().clear();
}
}
}
public void pulse() {
if(isConfirmed()) {
return;
}
if(unusedArea != null) {
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
}
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(opacityProperty(), 1.0)),
new KeyFrame(Duration.millis(500), new KeyValue(opacityProperty(), 0.7)),
new KeyFrame(Duration.millis(1000), new KeyValue(opacityProperty(), 1.0))
);
timeline.setCycleCount(1);
timeline.play();
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public static String getElapsed(long timestampUtc) {
long elapsed = calculateElapsedSeconds(timestampUtc);
if(elapsed < 60) {
return "Just now";
} else if(elapsed < 3600) {
return Math.round(elapsed / 60f) + "m ago";
} else if(elapsed < 86400) {
return Math.round(elapsed / 3600f) + "h ago";
} else {
return Math.round(elapsed / 86400d) + "d ago";
}
}
private String getFeeRateStyleName() {
double rate = getMedianFee();
int[] feeRateInterval = getFeeRateInterval(rate);
if(feeRateInterval[1] == Integer.MAX_VALUE) {
return "VSIZE2000-2200_COLOR";
}
int[] nextRateInterval = getFeeRateInterval(rate * 2);
String from = "VSIZE" + feeRateInterval[0] + "-" + feeRateInterval[1] + "_COLOR";
String to = "VSIZE" + nextRateInterval[0] + "-" + (nextRateInterval[1] == Integer.MAX_VALUE ? "2200" : nextRateInterval[1]) + "_COLOR";
return "linear-gradient(from 75% 0% to 100% 0%, " + from + " 0%, " + to + " 100%, " + from +")";
}
private int[] getFeeRateInterval(double medianFee) {
for(int i = 0; i < MEMPOOL_FEE_RATES_INTERVALS.size(); i++) {
int feeRate = MEMPOOL_FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == MEMPOOL_FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : MEMPOOL_FEE_RATES_INTERVALS.get(i + 1));
if(feeRate <= medianFee && nextFeeRate > medianFee) {
return new int[] { feeRate, nextFeeRate };
}
}
return new int[] { 1, 2 };
}
public int getWeight() {
return weightProperty.get();
}
public IntegerProperty weightProperty() {
return weightProperty;
}
public void setWeight(int weight) {
weightProperty.set(weight);
}
public double getMedianFee() {
return medianFeeProperty.get();
}
public DoubleProperty medianFee() {
return medianFeeProperty;
}
public void setMedianFee(double medianFee) {
medianFeeProperty.set(medianFee);
}
public int getHeight() {
return heightProperty.get();
}
public IntegerProperty heightProperty() {
return heightProperty;
}
public void setHeight(int height) {
heightProperty.set(height);
}
public int getTxCount() {
return txCountProperty.get();
}
public IntegerProperty txCountProperty() {
return txCountProperty;
}
public void setTxCount(int txCount) {
txCountProperty.set(txCount);
}
public long getTimestamp() {
return timestampProperty.get();
}
public LongProperty timestampProperty() {
return timestampProperty;
}
public void setTimestamp(long timestamp) {
timestampProperty.set(timestamp);
}
public String getElapsed() {
return elapsedProperty.get();
}
public StringProperty elapsedProperty() {
return elapsedProperty;
}
public void setElapsed(String elapsed) {
elapsedProperty.set(elapsed);
}
public boolean isConfirmed() {
return confirmedProperty.get();
}
public BooleanProperty confirmedProperty() {
return confirmedProperty;
}
public void setConfirmed(boolean confirmed) {
confirmedProperty.set(confirmed);
}
public FeeRatesSource getFeeRatesSource() {
return feeRatesSource.get();
}
public ObjectProperty<FeeRatesSource> feeRatesSourceProperty() {
return feeRatesSource;
}
public void setFeeRatesSource(FeeRatesSource feeRatesSource) {
this.feeRatesSource.set(feeRatesSource);
}
public static BlockCube fromBlockSummary(BlockSummary blockSummary) {
return new BlockCube(blockSummary.getWeight().orElse(0), blockSummary.getMedianFee().orElse(-1.0d), blockSummary.getHeight(),
blockSummary.getTransactionCount().orElse(0), blockSummary.getTimestamp().getTime(), true);
}
}

View file

@ -0,0 +1,367 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class CardImportPane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
private final KeystoreCardImport importer;
private List<ChildNumber> derivation;
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), importer.getWalletModel());
this.importer = importer;
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
}
@Override
protected Control createButton() {
importButton = new Button("Import");
Glyph tapGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
tapGlyph.setFontSize(12);
importButton.setGraphic(tapGlyph);
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importCard();
});
return importButton;
}
private void importCard() {
if(!isReaderAvailable()) {
setError("No reader", "No card reader was detected.");
importButton.setDisable(false);
return;
}
StringProperty messageProperty = new SimpleStringProperty();
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
try {
if(pin.get().length() < importer.getWalletModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? (!importer.getWalletModel().hasDefaultPin() && !importer.isInitialized() ? "Choose a PIN code" : "Enter PIN code") : "PIN code too short");
setContent(getPinAndDerivationEntry());
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
return;
}
if(!importer.isInitialized()) {
setDescription("Card not initialized");
setContent(getInitializationPanel(messageProperty));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
} catch(CardException e) {
setError("Card Error", e.getMessage());
importButton.setDisable(false);
return;
}
CardImportService cardImportService = new CardImportService(importer, pin.get(), derivation, messageProperty);
cardImportService.setOnSucceeded(event -> {
EventManager.get().post(new KeystoreImportEvent(cardImportService.getValue()));
});
cardImportService.setOnFailed(event -> {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinAndDerivationEntry());
} else {
log.error("Error importing keystore from card", event.getSource().getException());
setError("Import Error", rootCause.getMessage());
}
importButton.setDisable(false);
});
cardImportService.start();
}
private Node getInitializationPanel(StringProperty messageProperty) {
if(importer.getWalletModel().requiresSeedInitialization()) {
return getSeedInitializationPanel(messageProperty);
}
return getEntropyInitializationPanel(messageProperty);
}
private Node getSeedInitializationPanel(StringProperty messageProperty) {
VBox confirmationBox = new VBox(5);
CustomPasswordField confirmationPin = new ViewPasswordField();
confirmationPin.setPromptText("Re-enter chosen PIN");
confirmationBox.getChildren().add(confirmationPin);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
if(!pin.get().equals(confirmationPin.getText())) {
setError("PIN Error", "The confirmation PIN did not match");
return;
}
int pinSize = pin.get().length();
if(pinSize < importer.getWalletModel().getMinPinLength() || pinSize > importer.getWalletModel().getMaxPinLength()) {
setError("PIN Error", "PIN length must be between " + importer.getWalletModel().getMinPinLength() + " and " + importer.getWalletModel().getMaxPinLength() + " characters");
return;
}
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(importer.getWalletModel().toDisplayString() + " Seed Words", 12);
seedEntryDialog.initOwner(this.getScene().getWindow());
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
try {
List<String> mnemonicWords = optWords.get();
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
byte[] seedBytes = seed.getSeedBytes();
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), seedBytes, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
setDescription("Leave card on reader");
setExpanded(false);
importButton.setDisable(false);
});
cardInitializationService.setOnFailed(failEvent -> {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
});
cardInitializationService.start();
} catch(MnemonicException e) {
log.error("Invalid seed entered", e);
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
initializeButton.setDisable(false);
}
} else {
initializeButton.setDisable(false);
}
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(confirmationBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
return contentBox;
}
private Node getEntropyInitializationPanel(StringProperty messageProperty) {
VBox initTypeBox = new VBox(5);
RadioButton automatic = new RadioButton("Automatic (Recommended)");
RadioButton advanced = new RadioButton("Advanced");
TextField entropy = new TextField();
entropy.setPromptText("Enter input for user entropy");
entropy.setDisable(true);
ToggleGroup toggleGroup = new ToggleGroup();
automatic.setToggleGroup(toggleGroup);
advanced.setToggleGroup(toggleGroup);
automatic.setSelected(true);
toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
entropy.setDisable(newValue == automatic);
});
initTypeBox.getChildren().addAll(automatic, advanced, entropy);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8));
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), chainCode, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
setDescription("Leave card on reader");
setExpanded(false);
importButton.setDisable(false);
});
cardInitializationService.setOnFailed(failEvent -> {
Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
importButton.setDisable(false);
} else {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
}
});
cardInitializationService.start();
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(initTypeBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(initTypeBox, Priority.ALWAYS);
return contentBox;
}
private Node getPinAndDerivationEntry() {
VBox vBox = new VBox();
vBox.getChildren().add(getPinEntry());
vBox.getChildren().add(getDerivationEntry());
return vBox;
}
private Node getPinEntry() {
VBox vBox = new VBox();
CustomPasswordField pinField = new ViewPasswordField();
pinField.setPromptText("PIN Code");
importButton.setDefaultButton(true);
pin.bind(pinField.textProperty());
HBox.setHgrow(pinField, Priority.ALWAYS);
Platform.runLater(pinField::requestFocus);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 0, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
return vBox;
}
private Node getDerivationEntry() {
VBox vBox = new VBox();
CheckBox checkBox = new CheckBox("Use Custom Derivation");
Label customLabel = new Label("Derivation:");
TextField customDerivation = new TextField(KeyDerivation.writePath(derivation));
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(customDerivation, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
customDerivation.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue.isEmpty() || !KeyDerivation.isValid(newValue)) {
importButton.setDisable(true);
} else {
importButton.setDisable(false);
derivation = KeyDerivation.parsePath(newValue);
}
});
checkBox.managedProperty().bind(checkBox.visibleProperty());
customLabel.managedProperty().bind(customLabel.visibleProperty());
customDerivation.managedProperty().bind(customDerivation.visibleProperty());
customLabel.visibleProperty().bind(checkBox.visibleProperty().not());
customDerivation.visibleProperty().bind(checkBox.visibleProperty().not());
checkBox.selectedProperty().addListener((observable, oldValue, newValue) -> {
checkBox.setVisible(false);
});
HBox derivationBox = new HBox();
derivationBox.setAlignment(Pos.CENTER_LEFT);
derivationBox.setSpacing(20);
derivationBox.getChildren().addAll(checkBox, customLabel, customDerivation);
derivationBox.setPadding(new Insets(10, 30, 10, 30));
derivationBox.setPrefHeight(50);
vBox.getChildren().addAll(derivationBox);
return vBox;
}
public static class CardInitializationService extends Service<Void> {
private final KeystoreCardImport cardImport;
private final String pin;
private final byte[] chainCode;
private final StringProperty messageProperty;
public CardInitializationService(KeystoreCardImport cardImport, String pin, byte[] chainCode, StringProperty messageProperty) {
this.cardImport = cardImport;
this.pin = pin;
this.chainCode = chainCode;
this.messageProperty = messageProperty;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
cardImport.initialize(pin, chainCode, messageProperty);
return null;
}
};
}
}
public static class CardImportService extends Service<Keystore> {
private final KeystoreCardImport cardImport;
private final String pin;
private final List<ChildNumber> derivation;
private final StringProperty messageProperty;
public CardImportService(KeystoreCardImport cardImport, String pin, List<ChildNumber> derivation, StringProperty messageProperty) {
this.cardImport = cardImport;
this.pin = pin;
this.derivation = derivation;
this.messageProperty = messageProperty;
}
@Override
protected Task<Keystore> createTask() {
return new Task<>() {
@Override
protected Keystore call() throws Exception {
return cardImport.getKeystore(pin, derivation, messageProperty);
}
};
}
}
}

View file

@ -0,0 +1,108 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.scene.control.*;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
private final CustomPasswordField existingPin;
private final CustomPasswordField newPin;
private final CustomPasswordField newPinConfirm;
private final CheckBox backupFirst;
private final ButtonType okButtonType;
public CardPinDialog(WalletModel walletModel, boolean backupOnly) {
this.existingPin = new ViewPasswordField();
this.newPin = new ViewPasswordField();
this.newPinConfirm = new ViewPasswordField();
this.backupFirst = new CheckBox();
if(backupOnly) {
newPin.textProperty().bind(existingPin.textProperty());
newPinConfirm.textProperty().bind(existingPin.textProperty());
}
final DialogPane dialogPane = getDialogPane();
setTitle(backupOnly ? "Backup Card" : "Change Card PIN");
dialogPane.setHeaderText(backupOnly ? "Enter the current card PIN." : "Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(backupOnly ? 135 : 260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
lock.setFontSize(50);
dialogPane.setGraphic(lock);
Form form = new Form();
Fieldset fieldset = new Fieldset();
fieldset.setText("");
fieldset.setSpacing(10);
Field currentField = new Field();
currentField.setText("Current PIN:");
currentField.getInputs().add(existingPin);
Field newField = new Field();
newField.setText("New PIN:");
newField.getInputs().add(newPin);
Field confirmField = new Field();
confirmField.setText("Confirm new PIN:");
confirmField.getInputs().add(newPinConfirm);
Field backupField = new Field();
backupField.setText("Backup First:");
backupField.getInputs().add(backupFirst);
if(backupOnly) {
fieldset.getChildren().addAll(currentField);
} else {
fieldset.getChildren().addAll(currentField, newField, confirmField);
}
if(walletModel.supportsBackup()) {
fieldset.getChildren().add(backupField);
}
form.getChildren().add(fieldset);
dialogPane.setContent(form);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()));
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()));
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
});
okButtonType = new javafx.scene.control.ButtonType(backupOnly ? "Backup" : "Change", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
okButton.setPrefWidth(130);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()
|| newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()
|| !newPin.getText().equals(newPinConfirm.getText()),
existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty());
okButton.disableProperty().bind(isInvalid);
Platform.runLater(existingPin::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new CardPinChange(existingPin.getText(), newPin.getText(), backupFirst.isSelected()) : null);
}
public record CardPinChange(String currentPin, String newPin, boolean backupFirst) { }
}

View file

@ -1,30 +1,38 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit; import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.UnitFormat; import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry; import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry; import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry; import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.scene.control.ContentDisplay; import javafx.beans.property.IntegerProperty;
import javafx.scene.control.Tooltip; import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.control.TreeTableCell; import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import javafx.util.Duration; import javafx.util.Duration;
import org.controlsfx.tools.Platform;
import java.text.DecimalFormat; import java.text.DecimalFormat;
class CoinCell extends TreeTableCell<Entry, Number> { class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsListener {
private final Tooltip tooltip; private final CoinTooltip tooltip;
private final CoinContextMenu contextMenu;
private IntegerProperty confirmationsProperty;
public CoinCell() { public CoinCell() {
super(); super();
tooltip = new Tooltip(); tooltip = new CoinTooltip();
tooltip.setShowDelay(Duration.millis(500)); tooltip.setShowDelay(Duration.millis(500));
contextMenu = new CoinContextMenu();
getStyleClass().add("coin-cell"); getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) { if(OsType.getCurrent() == OsType.MACOS) {
getStyleClass().add("number-field"); getStyleClass().add("number-field");
} }
} }
@ -37,6 +45,7 @@ class CoinCell extends TreeTableCell<Entry, Number> {
setText(null); setText(null);
setGraphic(null); setGraphic(null);
setTooltip(null); setTooltip(null);
setContextMenu(null);
} else { } else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue(); Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry); EntryCell.applyRowStyles(this, entry);
@ -50,21 +59,18 @@ class CoinCell extends TreeTableCell<Entry, Number> {
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN); final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) { if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel()); tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
setText(btcValue); setText(btcValue);
} else { } else {
tooltip.setText(btcValue + " " + BitcoinUnit.BTC.getLabel()); tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
setText(satsValue); setText(satsValue);
} }
setTooltip(tooltip); setTooltip(tooltip);
String tooltipValue = tooltip.getText(); contextMenu.updateAmount(amount);
setContextMenu(contextMenu);
if(entry instanceof TransactionEntry transactionEntry) { if(entry instanceof TransactionEntry transactionEntry) {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")"); tooltip.showConfirmations(transactionEntry.confirmationsProperty(), transactionEntry.isCoinbase());
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
});
if(transactionEntry.isConfirming()) { if(transactionEntry.isConfirming()) {
ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations()); ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations());
@ -81,6 +87,8 @@ class CoinCell extends TreeTableCell<Entry, Number> {
} else if(entry instanceof UtxoEntry) { } else if(entry instanceof UtxoEntry) {
setGraphic(null); setGraphic(null);
} else if(entry instanceof HashIndexEntry) { } else if(entry instanceof HashIndexEntry) {
tooltip.hideConfirmations();
Region node = new Region(); Region node = new Region();
node.setPrefWidth(10); node.setPrefWidth(10);
setGraphic(node); setGraphic(node);
@ -94,4 +102,107 @@ class CoinCell extends TreeTableCell<Entry, Number> {
} }
} }
} }
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static final class CoinTooltip extends Tooltip {
private final IntegerProperty confirmationsProperty = new SimpleIntegerProperty();
private boolean showConfirmations;
private boolean isCoinbase;
private String value;
public void setValue(String value) {
this.value = value;
setTooltipText();
}
public void showConfirmations(IntegerProperty txEntryConfirmationsProperty, boolean coinbase) {
showConfirmations = true;
isCoinbase = coinbase;
int confirmations = txEntryConfirmationsProperty.get();
if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
confirmationsProperty.bind(txEntryConfirmationsProperty);
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
setTooltipText();
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
confirmationsProperty.unbind();
}
});
} else {
confirmationsProperty.unbind();
confirmationsProperty.set(confirmations);
}
setTooltipText();
}
public void hideConfirmations() {
showConfirmations = false;
isCoinbase = false;
confirmationsProperty.unbind();
setTooltipText();
}
private void setTooltipText() {
setText(value + (showConfirmations ? " (" + getConfirmationsDescription() + ")" : ""));
}
public String getConfirmationsDescription() {
int confirmations = confirmationsProperty.get();
if(confirmations == 0) {
return "Unconfirmed in mempool";
} else if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
return confirmations + " confirmation" + (confirmations == 1 ? "" : "s") + (isCoinbase ? ", immature coinbase" : "");
} else {
return BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM + "+ confirmations";
}
}
}
private static class CoinContextMenu extends ContextMenu {
private Number amount;
public void updateAmount(Number amount) {
if(amount.equals(this.amount)) {
return;
}
this.amount = amount;
getItems().clear();
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
copySatsValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(amount.toString());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyBtcValue = new MenuItem("Copy Value in BTC");
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(amount.longValue()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
}
}
} }

View file

@ -5,14 +5,14 @@ import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextInputControl; import javafx.scene.control.TextInputControl;
import java.text.DecimalFormat; import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.ParseException; import java.text.ParseException;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> { public class CoinTextFormatter extends TextFormatter<String> {
public CoinTextFormatter(UnitFormat unitFormat) { public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat)); super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
} }
public UnitFormat getUnitFormat() { public UnitFormat getUnitFormat() {
@ -29,7 +29,7 @@ public class CoinTextFormatter extends TextFormatter<String> {
private final Pattern coinValidation; private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) { public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat; this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols()); this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?"); this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
} }
@ -51,8 +51,14 @@ public class CoinTextFormatter extends TextFormatter<String> {
commasRemoved = newText.length() - noFractionCommaText.length(); commasRemoved = newText.length() - noFractionCommaText.length();
} }
if(!coinValidation.matcher(noFractionCommaText).matches()) { Matcher matcher = coinValidation.matcher(noFractionCommaText);
return null; if(!matcher.matches()) {
matcher.reset();
if(matcher.find()) {
noFractionCommaText = matcher.group();
} else {
return null;
}
} }
if(unitFormat.getGroupingSeparator().equals(change.getText())) { if(unitFormat.getGroupingSeparator().equals(change.getText())) {

View file

@ -1,10 +1,15 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit; import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.SortDirection;
import com.sparrowwallet.drongo.wallet.TableType;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletTable;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat; import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletTableChangedEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent; import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent; import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent; import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
@ -12,22 +17,38 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType; import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.Entry;
import io.reactivex.Observable;
import io.reactivex.subjects.PublishSubject;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.collections.ListChangeListener;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Hyperlink; import javafx.scene.control.*;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableView;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Date; import java.util.Date;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class CoinTreeTable extends TreeTableView<Entry> { public class CoinTreeTable extends TreeTableView<Entry> {
private TableType tableType;
private BitcoinUnit bitcoinUnit; private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat; private UnitFormat unitFormat;
private CurrencyRate currencyRate;
protected static final double STANDARD_WIDTH = 100.0;
private final PublishSubject<WalletTableChangedEvent> walletTableSubject = PublishSubject.create();
private final Observable<WalletTableChangedEvent> walletTableEvents = walletTableSubject.debounce(1, TimeUnit.SECONDS);
public TableType getTableType() {
return tableType;
}
public void setTableType(TableType tableType) {
this.tableType = tableType;
}
public BitcoinUnit getBitcoinUnit() { public BitcoinUnit getBitcoinUnit() {
return bitcoinUnit; return bitcoinUnit;
@ -64,6 +85,18 @@ public class CoinTreeTable extends TreeTableView<Entry> {
} }
} }
public CurrencyRate getCurrencyRate() {
return currencyRate;
}
public void setCurrencyRate(CurrencyRate currencyRate) {
this.currencyRate = currencyRate;
if(!getChildren().isEmpty()) {
refresh();
}
}
public void updateHistoryStatus(WalletHistoryStatusEvent event) { public void updateHistoryStatus(WalletHistoryStatusEvent event) {
if(getRoot() != null) { if(getRoot() != null) {
Entry entry = getRoot().getValue(); Entry entry = getRoot().getValue();
@ -93,7 +126,8 @@ public class CoinTreeTable extends TreeTableView<Entry> {
Hyperlink hyperlink = new Hyperlink(); Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30); hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> { hyperlink.setOnAction(event -> {
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate()); WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate(), false);
dlg.initOwner(this.getScene().getWindow());
Optional<Date> optDate = dlg.showAndWait(); Optional<Date> optDate = dlg.showAndWait();
if(optDate.isPresent()) { if(optDate.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet); Storage storage = AppServices.get().getOpenWallets().get(wallet);
@ -119,4 +153,108 @@ public class CoinTreeTable extends TreeTableView<Entry> {
stackPane.setAlignment(Pos.CENTER); stackPane.setAlignment(Pos.CENTER);
return stackPane; return stackPane;
} }
protected void setupColumnSort(int defaultColumnIndex, TreeTableColumn.SortType defaultSortType) {
WalletTable.Sort columnSort = getSavedColumnSort();
if(columnSort == null) {
columnSort = new WalletTable.Sort(defaultColumnIndex, getSortDirection(defaultSortType));
}
setSortColumn(columnSort);
getSortOrder().addListener((ListChangeListener<? super TreeTableColumn<Entry, ?>>) c -> {
if(c.next()) {
walletTableChanged();
}
});
for(TreeTableColumn<Entry, ?> column : getColumns()) {
column.sortTypeProperty().addListener((_, _, _) -> walletTableChanged());
}
}
protected void resetSortColumn() {
setSortColumn(getColumnSort());
}
protected void setSortColumn(WalletTable.Sort sort) {
if(sort.sortColumn() >= 0 && sort.sortColumn() < getColumns().size() && getSortOrder().isEmpty() && !getRoot().getChildren().isEmpty()) {
TreeTableColumn<Entry, ?> column = getColumns().get(sort.sortColumn());
column.setSortType(sort.sortDirection() == SortDirection.DESCENDING ? TreeTableColumn.SortType.DESCENDING : TreeTableColumn.SortType.ASCENDING);
getSortOrder().add(column);
}
}
private WalletTable.Sort getColumnSort() {
if(getSortOrder().isEmpty() || !getColumns().contains(getSortOrder().getFirst())) {
return new WalletTable.Sort(tableType == TableType.UTXOS ? getColumns().size() - 1 : 0, SortDirection.DESCENDING);
}
return new WalletTable.Sort(getColumns().indexOf(getSortOrder().getFirst()), getSortDirection(getSortOrder().getFirst().getSortType()));
}
private SortDirection getSortDirection(TreeTableColumn.SortType sortType) {
return sortType == TreeTableColumn.SortType.ASCENDING ? SortDirection.ASCENDING : SortDirection.DESCENDING;
}
private WalletTable.Sort getSavedColumnSort() {
if(getRoot() != null && getRoot().getValue() != null && getRoot().getValue().getWallet() != null) {
Wallet wallet = getRoot().getValue().getWallet();
WalletTable walletTable = wallet.getWalletTable(tableType);
if(walletTable != null) {
return walletTable.getSort();
}
}
return null;
}
@SuppressWarnings("deprecation")
protected void setupColumnWidths() {
Double[] savedWidths = getSavedColumnWidths();
for(int i = 0; i < getColumns().size(); i++) {
TreeTableColumn<Entry, ?> column = getColumns().get(i);
column.setPrefWidth(savedWidths != null && getColumns().size() == savedWidths.length ? savedWidths[i] : STANDARD_WIDTH);
}
//TODO: Replace with TreeTableView.CONSTRAINED_RESIZE_POLICY_FLEX_LAST_COLUMN when JavaFX 20+ has headless support
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
getColumns().getLast().widthProperty().addListener((_, _, _) -> walletTableChanged());
//Ignore initial resizes during layout
walletTableEvents.skip(3, TimeUnit.SECONDS).subscribe(event -> {
event.getWallet().getWalletTables().put(event.getTableType(), event.getWalletTable());
EventManager.get().post(event);
//Reset pref widths here so window resizes don't cause reversion to previously set pref widths
Double[] widths = event.getWalletTable().getWidths();
for(int i = 0; i < getColumns().size(); i++) {
TreeTableColumn<Entry, ?> column = getColumns().get(i);
column.setPrefWidth(widths != null && getColumns().size() == widths.length ? widths[i] : STANDARD_WIDTH);
}
});
}
private void walletTableChanged() {
if(getRoot() != null && getRoot().getValue() != null && getRoot().getValue().getWallet() != null) {
WalletTable walletTable = new WalletTable(tableType, getColumnWidths(), getColumnSort());
walletTableSubject.onNext(new WalletTableChangedEvent(getRoot().getValue().getWallet(), walletTable));
}
}
private Double[] getColumnWidths() {
return getColumns().stream().map(TableColumnBase::getWidth).toArray(Double[]::new);
}
private Double[] getSavedColumnWidths() {
if(getRoot() != null && getRoot().getValue() != null && getRoot().getValue().getWallet() != null) {
Wallet wallet = getRoot().getValue().getWallet();
WalletTable walletTable = wallet.getWalletTable(tableType);
if(walletTable != null) {
return walletTable.getWidths();
}
}
return null;
}
} }

View file

@ -6,10 +6,16 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Cursor; import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.ComboBox; import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import org.controlsfx.control.textfield.CustomTextField; import org.controlsfx.control.textfield.CustomTextField;
import java.util.List;
public class ComboBoxTextField extends CustomTextField { public class ComboBoxTextField extends CustomTextField {
private final ObjectProperty<ComboBox<?>> comboProperty = new SimpleObjectProperty<>(); private final ObjectProperty<ComboBox<?>> comboProperty = new SimpleObjectProperty<>();
@ -68,4 +74,53 @@ public class ComboBoxTextField extends CustomTextField {
public void setComboProperty(ComboBox<?> comboProperty) { public void setComboProperty(ComboBox<?> comboProperty) {
this.comboProperty.set(comboProperty); this.comboProperty.set(comboProperty);
} }
public ContextMenu getCustomContextMenu(List<MenuItem> customItems) {
return new CustomContextMenu(customItems);
}
public class CustomContextMenu extends ContextMenu {
public CustomContextMenu(List<MenuItem> customItems) {
super();
setFont(null);
MenuItem undo = new MenuItem("Undo");
undo.setOnAction(_ -> undo());
MenuItem redo = new MenuItem("Redo");
redo.setOnAction(_ -> redo());
MenuItem cut = new MenuItem("Cut");
cut.setOnAction(_ -> cut());
MenuItem copy = new MenuItem("Copy");
copy.setOnAction(_ -> copy());
MenuItem paste = new MenuItem("Paste");
paste.setOnAction(_ -> paste());
MenuItem delete = new MenuItem("Delete");
delete.setOnAction(_ -> deleteText(getSelection()));
MenuItem selectAll = new MenuItem("Select All");
selectAll.setOnAction(_ -> selectAll());
getItems().addAll(undo, redo, new SeparatorMenuItem(), cut, copy, paste, delete, new SeparatorMenuItem(), selectAll);
getItems().addAll(customItems);
setOnShowing(_ -> {
boolean hasSelection = getSelection().getLength() > 0;
boolean hasText = getText() != null && !getText().isEmpty();
boolean clipboardHasContent = Clipboard.getSystemClipboard().hasString();
undo.setDisable(!isUndoable());
redo.setDisable(!isRedoable());
cut.setDisable(!isEditable() || !hasSelection);
copy.setDisable(!hasSelection);
paste.setDisable(!isEditable() || !clipboardHasContent);
delete.setDisable(!hasSelection);
selectAll.setDisable(!hasText);
});
}
}
} }

View file

@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import static com.sparrowwallet.sparrow.AppServices.getActiveWindow;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
public class ConfirmationAlert extends Alert {
private final CheckBox dontAskAgain;
public ConfirmationAlert(String title, String contentText, ButtonType... buttons) {
super(AlertType.CONFIRMATION, contentText, buttons);
initOwner(getActiveWindow());
setStageIcon(getDialogPane().getScene().getWindow());
getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
setTitle(title);
setHeaderText(title);
VBox contentBox = new VBox(20);
contentBox.setPadding(new Insets(10, 20, 10, 20));
Label contentLabel = new Label(contentText);
contentLabel.setWrapText(true);
dontAskAgain = new CheckBox("Don't ask again");
contentBox.getChildren().addAll(contentLabel, dontAskAgain);
getDialogPane().setContent(contentBox);
}
public boolean isDontAskAgain() {
return dontAskAgain.isSelected();
}
}

View file

@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.property.IntegerProperty;
public interface ConfirmationsListener {
IntegerProperty getConfirmationsProperty();
}

View file

@ -10,12 +10,17 @@ import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip; import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent; import javafx.scene.input.ClipboardContent;
import javafx.event.EventHandler;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
public class CopyableCoinLabel extends CopyableLabel { public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1); private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip; private final Tooltip tooltip;
private final CoinContextMenu contextMenu; private final CoinContextMenu contextMenu;
private BitcoinUnit bitcoinUnit;
public CopyableCoinLabel() { public CopyableCoinLabel() {
this("Unknown"); this("Unknown");
} }
@ -23,6 +28,25 @@ public class CopyableCoinLabel extends CopyableLabel {
public CopyableCoinLabel(String text) { public CopyableCoinLabel(String text) {
super(text); super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit())); valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
setOnMouseClicked(event -> {
if(!event.getButton().equals(MouseButton.PRIMARY)) {
return;
}
if(bitcoinUnit == null) {
bitcoinUnit = Config.get().getBitcoinUnit();
}
if(bitcoinUnit == BitcoinUnit.SATOSHIS) {
bitcoinUnit = BitcoinUnit.BTC;
} else {
bitcoinUnit = BitcoinUnit.SATOSHIS;
}
refresh(Config.get().getUnitFormat(), bitcoinUnit);
});
tooltip = new Tooltip(); tooltip = new Tooltip();
contextMenu = new CoinContextMenu(); contextMenu = new CoinContextMenu();
} }
@ -63,6 +87,8 @@ public class CopyableCoinLabel extends CopyableLabel {
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS); unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
} }
this.bitcoinUnit = unit;
if(unit.equals(BitcoinUnit.BTC)) { if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue); tooltip.setText(satsValue);
setText(btcValue); setText(btcValue);

View file

@ -10,6 +10,7 @@ import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler; import javafx.event.EventHandler;
import javafx.scene.Cursor; import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Tooltip; import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent; import javafx.scene.input.ClipboardContent;
@ -52,6 +53,7 @@ public class CopyableTextField extends CustomTextField {
selectedTextProperty().removeListener(selectionListener); selectedTextProperty().removeListener(selectionListener);
} }
}); });
setContextMenu(new ContextMenu());
} }
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) { private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {

View file

@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry; import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos; import javafx.geometry.Pos;
@ -12,6 +11,8 @@ import javafx.util.Duration;
import java.text.DateFormat; import java.text.DateFormat;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import static com.sparrowwallet.sparrow.control.EntryCell.HashIndexEntryContextMenu;
public class DateCell extends TreeTableCell<Entry, Entry> { public class DateCell extends TreeTableCell<Entry, Entry> {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm"); private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
@ -36,11 +37,11 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry; UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) { if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"))); setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")));
setContextMenu(null); setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), utxoEntry));
} else if(utxoEntry.getHashIndex().getDate() != null) { } else if(utxoEntry.getHashIndex().getDate() != null) {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate()); String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date); setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex())); setContextMenu(new DateContextMenu(getTreeTableView(), utxoEntry, date));
} else { } else {
setText("Unknown"); setText("Unknown");
setContextMenu(null); setContextMenu(null);
@ -56,8 +57,10 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
} }
} }
private static class DateContextMenu extends ContextMenu { private static class DateContextMenu extends HashIndexEntryContextMenu {
public DateContextMenu(String date, BlockTransactionHashIndex reference) { public DateContextMenu(TreeTableView<Entry> treeTableView, UtxoEntry utxoEntry, String date) {
super(treeTableView, utxoEntry);
MenuItem copyDate = new MenuItem("Copy Date"); MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> { copyDate.setOnAction(AE -> {
hide(); hide();
@ -70,7 +73,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
copyHeight.setOnAction(AE -> { copyHeight.setOnAction(AE -> {
hide(); hide();
ClipboardContent content = new ClipboardContent(); ClipboardContent content = new ClipboardContent();
content.putString(reference.getHeight() > 0 ? Integer.toString(reference.getHeight()) : "Mempool"); content.putString(utxoEntry.getHashIndex().getHeight() > 0 ? Integer.toString(utxoEntry.getHashIndex().getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content); Clipboard.getSystemClipboard().setContent(content);
}); });

View file

@ -3,13 +3,14 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils; import com.sparrowwallet.sparrow.io.PdfUtils;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.Button; import javafx.scene.control.Button;
public class DescriptorQRDisplayDialog extends QRDisplayDialog { public class DescriptorQRDisplayDialog extends QRDisplayDialog {
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur) { public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur, BBQR bbqr, boolean selectBbqrButton) {
super(ur); super(ur, bbqr, false, false, selectBbqrButton);
DialogPane dialogPane = getDialogPane(); DialogPane dialogPane = getDialogPane();
final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2); final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2);
@ -19,7 +20,7 @@ public class DescriptorQRDisplayDialog extends QRDisplayDialog {
pdfButton.setGraphicTextGap(5); pdfButton.setGraphicTextGap(5);
pdfButton.setGraphic(getGlyph(FontAwesome5.Glyph.FILE_PDF)); pdfButton.setGraphic(getGlyph(FontAwesome5.Glyph.FILE_PDF));
pdfButton.addEventFilter(ActionEvent.ACTION, event -> { pdfButton.addEventFilter(ActionEvent.ACTION, event -> {
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur); PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur, isUseBbqrEncoding() ? bbqr : null);
event.consume(); event.consume();
}); });
} }

View file

@ -68,7 +68,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
stackPane.getChildren().addAll(anchorPane, scanBox); stackPane.getChildren().addAll(anchorPane, scanBox);
List<Device> devices = AppServices.getDevices(); List<Device> devices = getDevices();
if(devices == null || devices.isEmpty()) { if(devices == null || devices.isEmpty()) {
scanButton.setDefaultButton(true); scanButton.setDefaultButton(true);
scanBox.setVisible(true); scanBox.setVisible(true);
@ -91,11 +91,16 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
dialogPane.setPrefWidth(500); dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360); dialogPane.setPrefHeight(360);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this); AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult()); setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
} }
protected List<Device> getDevices() {
return AppServices.getDevices();
}
private void scan() { private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null); Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> { enumerateService.setOnSucceeded(workerStateEvent -> {

View file

@ -9,11 +9,11 @@ import com.sparrowwallet.sparrow.io.Device;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class DeviceAddressDialog extends DeviceDialog<String> { public class DeviceDisplayAddressDialog extends DeviceDialog<String> {
private final Wallet wallet; private final Wallet wallet;
private final OutputDescriptor outputDescriptor; private final OutputDescriptor outputDescriptor;
public DeviceAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) { public DeviceDisplayAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
super(outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList())); super(outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList()));
this.wallet = wallet; this.wallet = wallet;
this.outputDescriptor = outputDescriptor; this.outputDescriptor = outputDescriptor;

View file

@ -0,0 +1,29 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.DeviceAddressEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceGetAddressDialog extends DeviceDialog<Address> {
public DeviceGetAddressDialog(List<String> operationFingerprints) {
super(operationFingerprints);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(DevicePane.DeviceOperation.GET_ADDRESS, device, defaultDevice);
}
@Subscribe
public void deviceAddress(DeviceAddressEvent event) {
setResult(event.getAddress());
}
}

View file

@ -1,25 +1,30 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber; import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.Policy; import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Device; import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.Node; import javafx.scene.Node;
@ -34,8 +39,10 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class DevicePane extends TitledDescriptionPane { public class DevicePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(DevicePane.class); private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
@ -58,13 +65,17 @@ public class DevicePane extends TitledDescriptionPane {
private Button displayAddressButton; private Button displayAddressButton;
private Button signMessageButton; private Button signMessageButton;
private Button discoverKeystoresButton; private Button discoverKeystoresButton;
private ButtonBase getPrivateKeyButton;
private Button getAddressButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty(""); private final SimpleStringProperty passphrase = new SimpleStringProperty("");
private final SimpleStringProperty pin = new SimpleStringProperty("");
private final StringProperty messageProperty = new SimpleStringProperty("");
private boolean defaultDevice; private boolean defaultDevice;
public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation requiredDerivation) { public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation requiredDerivation) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.IMPORT; this.deviceOperation = DeviceOperation.IMPORT;
this.wallet = wallet; this.wallet = wallet;
this.psbt = null; this.psbt = null;
@ -83,13 +94,17 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device); initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, importButton); buttonBox.getChildren().addAll(setPassphraseButton, importButton);
} }
public DevicePane(PSBT psbt, Device device, boolean defaultDevice) { public DevicePane(Wallet wallet, PSBT psbt, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.SIGN; this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null; this.wallet = wallet;
this.psbt = psbt; this.psbt = psbt;
this.outputDescriptor = null; this.outputDescriptor = null;
this.keyDerivation = null; this.keyDerivation = null;
@ -106,11 +121,15 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device); initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signButton); buttonBox.getChildren().addAll(setPassphraseButton, signButton);
} }
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device, boolean defaultDevice) { public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS; this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
this.wallet = wallet; this.wallet = wallet;
this.psbt = null; this.psbt = null;
@ -133,7 +152,7 @@ public class DevicePane extends TitledDescriptionPane {
} }
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) { public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.SIGN_MESSAGE; this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet; this.wallet = wallet;
this.psbt = null; this.psbt = null;
@ -152,11 +171,15 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device); initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton); buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
} }
public DevicePane(Wallet wallet, List<StandardAccount> availableAccounts, Device device, boolean defaultDevice) { public DevicePane(Wallet wallet, List<StandardAccount> availableAccounts, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.DISCOVER_KEYSTORES; this.deviceOperation = DeviceOperation.DISCOVER_KEYSTORES;
this.wallet = wallet; this.wallet = wallet;
this.psbt = null; this.psbt = null;
@ -178,6 +201,41 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton); buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton);
} }
public DevicePane(DeviceOperation deviceOperation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = deviceOperation;
this.wallet = null;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
this.availableAccounts = null;
setDefaultStatus();
showHideLink.setVisible(false);
ButtonBase button;
if(deviceOperation == DeviceOperation.GET_PRIVATE_KEY) {
createGetPrivateKeyButton();
button = getPrivateKeyButton;
} else if(deviceOperation == DeviceOperation.GET_ADDRESS) {
createGetAddressButton();
button = getAddressButton;
} else {
throw new UnsupportedOperationException("Cannot construct device pane for operation " + deviceOperation);
}
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().add(button);
}
private void initialise(Device device) { private void initialise(Device device) {
if(device.isNeedsPinSent()) { if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice); unlockButton.setDefaultButton(defaultDevice);
@ -201,7 +259,7 @@ public class DevicePane extends TitledDescriptionPane {
} }
private void setDefaultStatus() { private void setDefaultStatus() {
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked"); setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : device.isCard() ? "Leave card on reader" : "Unlocked");
} }
private void createUnlockButton() { private void createUnlockButton() {
@ -239,7 +297,7 @@ public class DevicePane extends TitledDescriptionPane {
if(importButton instanceof SplitMenuButton importMenuButton) { if(importButton instanceof SplitMenuButton importMenuButton) {
if(wallet.getScriptType() == null) { if(wallet.getScriptType() == null) {
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH}; ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH, ScriptType.P2TR};
for(ScriptType scriptType : scriptTypes) { for(ScriptType scriptType : scriptTypes) {
MenuItem item = new MenuItem(scriptType.getDescription()); MenuItem item = new MenuItem(scriptType.getDescription());
final List<ChildNumber> derivation = scriptType.getDefaultDerivation(); final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
@ -321,6 +379,60 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setVisible(false); discoverKeystoresButton.setVisible(false);
} }
private void createGetPrivateKeyButton() {
int currentSlot = 0;
boolean initialized = true;
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), null);
currentSlot = cardApi.getCurrentSlot();
initialized = cardApi.isInitialized();
} catch(Exception e) {
//ignore
}
getPrivateKeyButton = currentSlot > 0 ? new SplitMenuButton() : new Button();
getPrivateKeyButton.setAlignment(Pos.CENTER_RIGHT);
getPrivateKeyButton.setText("Get Private Key");
getPrivateKeyButton.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(null);
});
if(getPrivateKeyButton instanceof SplitMenuButton getPrivateKeyMenuButton) {
int[] previousSlots = IntStream.range(0, currentSlot).toArray();
for(int previousSlot : previousSlots) {
MenuItem previousSlotItem = new MenuItem("Slot #" + previousSlot);
previousSlotItem.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(previousSlot);
});
getPrivateKeyMenuButton.getItems().add(previousSlotItem);
}
if(initialized) {
int finalSlot = currentSlot;
MenuItem currentSlotItem = new MenuItem("Current Slot");
currentSlotItem.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(finalSlot);
});
getPrivateKeyMenuButton.getItems().add(currentSlotItem);
}
}
getPrivateKeyButton.managedProperty().bind(getPrivateKeyButton.visibleProperty());
getPrivateKeyButton.setVisible(false);
}
private void createGetAddressButton() {
getAddressButton = new Button("Get Address");
getAddressButton.setAlignment(Pos.CENTER_RIGHT);
getAddressButton.setOnAction(event -> {
getAddressButton.setDisable(true);
getAddress();
});
getAddressButton.managedProperty().bind(getAddressButton.visibleProperty());
getAddressButton.setVisible(false);
}
private void unlock(Device device) { private void unlock(Device device) {
if(device.getModel().requiresPinPrompt()) { if(device.getModel().requiresPinPrompt()) {
promptPin(); promptPin();
@ -341,20 +453,26 @@ public class DevicePane extends TitledDescriptionPane {
}); });
vBox.getChildren().addAll(pinField, enterPinButton); vBox.getChildren().addAll(pinField, enterPinButton);
TilePane tilePane = new TilePane(); GridPane gridPane = new GridPane();
tilePane.setPrefColumns(3); gridPane.setHgap(10);
tilePane.setHgap(10); gridPane.setVgap(10);
tilePane.setVgap(10); gridPane.setMaxWidth(150);
tilePane.setMaxWidth(150); gridPane.setMaxHeight(device.getModel().hasZeroInPin() ? 160 : 120);
tilePane.setMaxHeight(120);
int[] digits = new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3}; int[] digits = device.getModel().hasZeroInPin() ? new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3, 0} : new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3};
for(int i = 0; i < digits.length; i++) { for(int i = 0; i < digits.length; i++) {
Button pinButton = new Button(); Button pinButton = new Button();
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, "CIRCLE"); Glyph circle = new Glyph(FontAwesome5.FONT_NAME, "CIRCLE");
pinButton.setGraphic(circle); pinButton.setGraphic(circle);
pinButton.setUserData(digits[i]); pinButton.setUserData(digits[i]);
tilePane.getChildren().add(pinButton); GridPane.setRowIndex(pinButton, i / 3);
GridPane.setColumnIndex(pinButton, i % 3);
if((i / 3) == 3) {
GridPane.setHgrow(pinButton, Priority.ALWAYS);
GridPane.setColumnSpan(pinButton, 3);
pinButton.setMaxWidth(Double.MAX_VALUE);
}
gridPane.getChildren().add(pinButton);
pinButton.setOnAction(event -> { pinButton.setOnAction(event -> {
pinField.setText(pinField.getText() + pinButton.getUserData()); pinField.setText(pinField.getText() + pinButton.getUserData());
}); });
@ -362,7 +480,7 @@ public class DevicePane extends TitledDescriptionPane {
HBox contentBox = new HBox(); HBox contentBox = new HBox();
contentBox.setSpacing(50); contentBox.setSpacing(50);
contentBox.getChildren().add(tilePane); contentBox.getChildren().add(gridPane);
contentBox.getChildren().add(vBox); contentBox.getChildren().add(vBox);
contentBox.setPadding(new Insets(10, 0, 10, 0)); contentBox.setPadding(new Insets(10, 0, 10, 0));
contentBox.setAlignment(Pos.TOP_CENTER); contentBox.setAlignment(Pos.TOP_CENTER);
@ -554,7 +672,36 @@ public class DevicePane extends TitledDescriptionPane {
} }
private void importKeystore(List<ChildNumber> derivation) { private void importKeystore(List<ChildNumber> derivation) {
if(device.getFingerprint() == null) { if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? (device.getModel().hasDefaultPin() ? "Enter PIN code" : "Choose a PIN code") : "PIN code too short");
setContent(getCardPinEntry(importButton));
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
return;
}
setDescription("Card not initialized");
setContent(getCardInitializationPanel(cardApi, importButton, DeviceOperation.IMPORT));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
Service<Keystore> importService = cardApi.getImportService(derivation, messageProperty);
handleCardOperation(importService, importButton, "Import", true, event -> {
importKeystore(derivation, importService.getValue());
});
} catch(Exception e) {
log.error("Import Error: " + e.getMessage(), e);
setError("Import Error", e.getMessage());
importButton.setDisable(false);
}
} else if(device.getFingerprint() == null) {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get()); Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get());
enumerateService.setOnSucceeded(workerStateEvent -> { enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue(); List<Device> devices = enumerateService.getValue();
@ -591,18 +738,7 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath)); keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub)); keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
if(wallet.getScriptType() == null) { importKeystore(derivation, keystore);
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
} catch(Exception e) { } catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage()); setError("Could not retrieve xpub", e.getMessage());
} }
@ -616,27 +752,85 @@ public class DevicePane extends TitledDescriptionPane {
getXpubService.start(); getXpubService.start();
} }
private void importKeystore(List<ChildNumber> derivation, Keystore keystore) {
if(wallet.getScriptType() == null) {
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
}
private void sign() { private void sign() {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt); if(device.isCard()) {
signPSBTService.setOnSucceeded(workerStateEvent -> { try {
PSBT signedPsbt = signPSBTService.getValue(); CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt)); Service<PSBT> signService = cardApi.getSignService(wallet, psbt, messageProperty);
handleCardOperation(signService, signButton, "Signing", true, event -> {
EventManager.get().post(new PSBTSignedEvent(psbt, signService.getValue()));
});
} catch(Exception e) {
log.error("Signing Error: " + e.getMessage(), e);
setError("Signing Error", e.getMessage());
signButton.setDisable(false);
}
} else {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt,
OutputDescriptor.getOutputDescriptor(wallet), wallet.getFullName(), getDeviceRegistration());
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
updateDeviceRegistrations(signPSBTService.getNewDeviceRegistrations());
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
showHideLink.setVisible(false);
signPSBTService.start();
}
}
private void handleCardOperation(Service<?> service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler<WorkerStateEvent> successHandler) {
if(pinRequired && pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(operationButton));
showHideLink.setVisible(false);
setExpanded(true);
operationButton.setDisable(false);
return;
}
service.setOnSucceeded(successHandler);
service.setOnFailed(event -> {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getCardPinEntry(operationButton));
} else {
log.error(operationDescription + " Error: " + rootCause.getMessage(), event.getSource().getException());
setError(operationDescription + " Error", rootCause.getMessage());
}
operationButton.setDisable(false);
}); });
signPSBTService.setOnFailed(workerStateEvent -> { service.start();
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
showHideLink.setVisible(false);
signPSBTService.start();
} }
private void displayAddress() { private void displayAddress() {
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), outputDescriptor); Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), outputDescriptor,
OutputDescriptor.getOutputDescriptor(wallet), wallet.getFullName(), getDeviceRegistration());
displayAddressService.setOnSucceeded(successEvent -> { displayAddressService.setOnSucceeded(successEvent -> {
String address = displayAddressService.getValue(); String address = displayAddressService.getValue();
EventManager.get().post(new AddressDisplayedEvent(address)); EventManager.get().post(new AddressDisplayedEvent(address));
updateDeviceRegistrations(displayAddressService.getNewDeviceRegistrations());
}); });
displayAddressService.setOnFailed(failedEvent -> { displayAddressService.setOnFailed(failedEvent -> {
setError("Could not display address", displayAddressService.getException().getMessage()); setError("Could not display address", displayAddressService.getException().getMessage());
@ -646,18 +840,53 @@ public class DevicePane extends TitledDescriptionPane {
displayAddressService.start(); displayAddressService.start();
} }
private byte[] getDeviceRegistration() {
Optional<Keystore> optKeystore = wallet.getKeystores().stream()
.filter(keystore -> keystore.getKeyDerivation().getMasterFingerprint().equals(device.getFingerprint()) && keystore.getDeviceRegistration() != null).findFirst();
return optKeystore.map(Keystore::getDeviceRegistration).orElse(null);
}
private void updateDeviceRegistrations(Set<byte[]> newDeviceRegistrations) {
if(!newDeviceRegistrations.isEmpty()) {
List<Keystore> registrationKeystores = getDeviceRegistrationKeystores();
if(!registrationKeystores.isEmpty()) {
registrationKeystores.forEach(keystore -> keystore.setDeviceRegistration(newDeviceRegistrations.iterator().next()));
EventManager.get().post(new KeystoreDeviceRegistrationsChangedEvent(wallet, registrationKeystores));
}
}
}
private List<Keystore> getDeviceRegistrationKeystores() {
return wallet.getKeystores().stream().filter(keystore -> keystore.getKeyDerivation().getMasterFingerprint().equals(device.getFingerprint())).toList();
}
private void signMessage() { private void signMessage() {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath()); if(device.isCard()) {
signMessageService.setOnSucceeded(successEvent -> { try {
String signature = signMessageService.getValue(); CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
EventManager.get().post(new MessageSignedEvent(wallet, signature)); Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty);
}); handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> {
signMessageService.setOnFailed(failedEvent -> { String signature = signMessageService.getValue();
setError("Could not sign message", signMessageService.getException().getMessage()); EventManager.get().post(new MessageSignedEvent(wallet, signature));
signMessageButton.setDisable(false); });
}); } catch(Exception e) {
setDescription("Signing message..."); log.error("Signing Error: " + e.getMessage(), e);
signMessageService.start(); setError("Signing Error", e.getMessage());
signButton.setDisable(false);
}
} else {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
}
} }
private void discoverKeystores() { private void discoverKeystores() {
@ -717,6 +946,55 @@ public class DevicePane extends TitledDescriptionPane {
getXpubsService.start(); getXpubsService.start();
} }
private void getPrivateKey(Integer slot) {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<ECKey> privateKeyService = cardApi.getPrivateKeyService(slot, messageProperty);
handleCardOperation(privateKeyService, getPrivateKeyButton, "Private Key", true, event -> {
EventManager.get().post(new DeviceGetPrivateKeyEvent(privateKeyService.getValue(), cardApi.getDefaultScriptType()));
});
} catch(Exception e) {
log.error("Private Key Error: " + e.getMessage(), e);
setError("Private Key Error", e.getMessage());
getPrivateKeyButton.setDisable(false);
}
}
}
private void getAddress() {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(getAddressButton));
showHideLink.setVisible(false);
setExpanded(true);
getAddressButton.setDisable(false);
return;
}
setDescription("Card not initialized");
setContent(getCardInitializationPanel(cardApi, getAddressButton, DeviceOperation.GET_ADDRESS));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
Service<Address> addressService = cardApi.getAddressService(messageProperty);
handleCardOperation(addressService, getAddressButton, "Address", false, event -> {
EventManager.get().post(new DeviceAddressEvent(addressService.getValue()));
});
} catch(Exception e) {
log.error("Address Error: " + e.getMessage(), e);
setError("Address Error", e.getMessage());
getAddressButton.setDisable(false);
}
}
}
private void showOperationButton() { private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) { if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) { if(defaultDevice) {
@ -724,7 +1002,7 @@ public class DevicePane extends TitledDescriptionPane {
} }
importButton.setVisible(true); importButton.setVisible(true);
showHideLink.setText("Show derivation..."); showHideLink.setText("Show derivation...");
showHideLink.setVisible(true); showHideLink.setVisible(!device.isCard());
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation(); List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation())); setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) { } else if(deviceOperation.equals(DeviceOperation.SIGN)) {
@ -743,6 +1021,16 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setDefaultButton(defaultDevice); discoverKeystoresButton.setDefaultButton(defaultDevice);
discoverKeystoresButton.setVisible(true); discoverKeystoresButton.setVisible(true);
showHideLink.setVisible(false); showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_PRIVATE_KEY)) {
if(defaultDevice) {
getPrivateKeyButton.getStyleClass().add("default-button");
}
getPrivateKeyButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_ADDRESS)) {
getAddressButton.setDefaultButton(defaultDevice);
getAddressButton.setVisible(true);
showHideLink.setVisible(false);
} }
} }
@ -750,7 +1038,7 @@ public class DevicePane extends TitledDescriptionPane {
TextField derivationField = new TextField(); TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path"); derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation)); derivationField.setText(KeyDerivation.writePath(derivation));
derivationField.setDisable(keyDerivation != null); derivationField.setDisable(device.isCard() || keyDerivation != null);
HBox.setHgrow(derivationField, Priority.ALWAYS); HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport(); ValidationSupport validationSupport = new ValidationSupport();
@ -785,11 +1073,161 @@ public class DevicePane extends TitledDescriptionPane {
return contentBox; return contentBox;
} }
private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
if(device.getModel().requiresSeedInitialization()) {
return getCardSeedInitializationPanel(cardApi, operationButton, deviceOperation);
}
return getCardEntropyInitializationPanel(cardApi, operationButton, deviceOperation);
}
private Node getCardSeedInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
VBox confirmationBox = new VBox(5);
CustomPasswordField confirmationPin = new ViewPasswordField();
confirmationPin.setPromptText("Re-enter chosen PIN");
confirmationBox.getChildren().add(confirmationPin);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
if(!pin.get().equals(confirmationPin.getText())) {
setError("PIN Error", "The confirmation PIN did not match");
return;
}
int pinSize = pin.get().length();
if(pinSize < device.getModel().getMinPinLength() || pinSize > device.getModel().getMaxPinLength()) {
setError("PIN Error", "PIN length must be between " + device.getModel().getMinPinLength() + " and " + device.getModel().getMaxPinLength() + " characters");
return;
}
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(device.getModel().toDisplayString() + " Seed Words", 12);
seedEntryDialog.initOwner(this.getScene().getWindow());
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
try {
List<String> mnemonicWords = optWords.get();
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
byte[] seedBytes = seed.getSeedBytes();
Service<Void> cardInitializationService = cardApi.getInitializationService(seedBytes, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
operationButton.setDisable(false);
setDefaultStatus();
setExpanded(false);
});
cardInitializationService.setOnFailed(failEvent -> {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
});
cardInitializationService.start();
} catch(MnemonicException e) {
log.error("Invalid seed entered", e);
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
initializeButton.setDisable(false);
}
} else {
initializeButton.setDisable(false);
}
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(confirmationBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
return contentBox;
}
private Node getCardEntropyInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
VBox initTypeBox = new VBox(5);
RadioButton automatic = new RadioButton("Automatic (Recommended)");
RadioButton advanced = new RadioButton("Advanced");
TextField entropy = new TextField();
entropy.setPromptText("Enter input for user entropy");
entropy.setDisable(true);
ToggleGroup toggleGroup = new ToggleGroup();
automatic.setToggleGroup(toggleGroup);
advanced.setToggleGroup(toggleGroup);
automatic.setSelected(true);
toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
entropy.setDisable(newValue == automatic);
});
initTypeBox.getChildren().addAll(automatic, advanced, entropy);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8));
Service<Void> cardInitializationService = cardApi.getInitializationService(chainCode, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
if(deviceOperation == DeviceOperation.IMPORT) {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
} else if(deviceOperation == DeviceOperation.GET_ADDRESS) {
AppServices.showSuccessDialog("Card Reinitialized", "The card was successfully reinitialized.\n\nYou can now retrieve the new deposit address.");
}
operationButton.setDisable(false);
setDefaultStatus();
setExpanded(false);
});
cardInitializationService.setOnFailed(failEvent -> {
Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getCardPinEntry(operationButton));
operationButton.setDisable(false);
} else {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
}
});
cardInitializationService.start();
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(initTypeBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(initTypeBox, Priority.ALWAYS);
return contentBox;
}
private Node getCardPinEntry(ButtonBase operationButton) {
VBox vBox = new VBox();
CustomPasswordField pinField = new ViewPasswordField();
pinField.setPromptText("PIN Code");
if(operationButton instanceof Button defaultButton) {
defaultButton.setDefaultButton(true);
}
pin.bind(pinField.textProperty());
HBox.setHgrow(pinField, Priority.ALWAYS);
Platform.runLater(pinField::requestFocus);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
return vBox;
}
public Device getDevice() { public Device getDevice() {
return device; return device;
} }
public enum DeviceOperation { public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES; IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, GET_PRIVATE_KEY, GET_ADDRESS;
} }
} }

View file

@ -2,17 +2,24 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe; import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent; import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device; import com.sparrowwallet.sparrow.io.Device;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List; import java.util.List;
public class DeviceSignDialog extends DeviceDialog<PSBT> { public class DeviceSignDialog extends DeviceDialog<PSBT> {
private static final Logger log = LoggerFactory.getLogger(DeviceSignDialog.class);
private final Wallet wallet;
private final PSBT psbt; private final PSBT psbt;
public DeviceSignDialog(List<String> operationFingerprints, PSBT psbt) { public DeviceSignDialog(Wallet wallet, List<String> operationFingerprints, PSBT psbt) {
super(operationFingerprints); super(operationFingerprints);
this.wallet = wallet;
this.psbt = psbt; this.psbt = psbt;
EventManager.get().register(this); EventManager.get().register(this);
setOnCloseRequest(event -> { setOnCloseRequest(event -> {
@ -23,7 +30,7 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
@Override @Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) { protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(psbt, device, defaultDevice); return new DevicePane(wallet, psbt, device, defaultDevice);
} }
@Subscribe @Subscribe

View file

@ -0,0 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.DeviceGetPrivateKeyEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceUnsealDialog extends DeviceDialog<DeviceUnsealDialog.DevicePrivateKey> {
public DeviceUnsealDialog(List<String> operationFingerprints) {
super(operationFingerprints);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(DevicePane.DeviceOperation.GET_PRIVATE_KEY, device, defaultDevice);
}
@Subscribe
public void deviceGetPrivateKey(DeviceGetPrivateKeyEvent event) {
setResult(new DevicePrivateKey(event.getPrivateKey(), event.getScriptType()));
}
public record DevicePrivateKey(ECKey privateKey, ScriptType scriptType) {}
}

View file

@ -0,0 +1,85 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.StackPane;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.util.Locale;
public class DialogImage extends StackPane {
private static final Logger log = LoggerFactory.getLogger(DialogImage.class);
public static final int WIDTH = 50;
public static final int HEIGHT = 50;
public ObjectProperty<DialogImage.Type> typeProperty = new SimpleObjectProperty<>();
public DialogImage() {
setPrefSize(WIDTH, HEIGHT);
this.typeProperty.addListener((observable, oldValue, type) -> {
refresh(type);
});
}
public DialogImage(@NamedArg("type") Type type) {
this();
this.typeProperty.set(type);
}
public void refresh() {
Type type = getType();
refresh(type);
}
protected void refresh(Type type) {
SVGImage svgImage;
if(Config.get().getTheme() == Theme.DARK) {
svgImage = loadSVGImage("/image/dialog/" + type.name().toLowerCase(Locale.ROOT) + "-invert.svg");
} else {
svgImage = loadSVGImage("/image/dialog/" + type.name().toLowerCase(Locale.ROOT) + ".svg");
}
if(svgImage != null) {
getChildren().clear();
getChildren().add(svgImage);
}
}
public Type getType() {
return typeProperty.get();
}
public ObjectProperty<Type> typeProperty() {
return typeProperty;
}
public void setType(Type type) {
this.typeProperty.set(type);
}
private SVGImage loadSVGImage(String imageName) {
try {
URL url = AppServices.class.getResource(imageName);
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
log.error("Could not find image " + imageName);
}
return null;
}
public enum Type {
SPARROW, SEED, PAYNYM, BORDERWALLETS, USERADD, WHIRLPOOL;
}
}

View file

@ -0,0 +1,777 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.pgp.PGPKeySource;
import com.sparrowwallet.drongo.pgp.PGPUtils;
import com.sparrowwallet.drongo.pgp.PGPVerificationException;
import com.sparrowwallet.drongo.pgp.PGPVerificationResult;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.net.VersionCheckService;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Dragboard;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppController.DRAG_OVER_CLASS;
public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(DownloadVerifierDialog.class);
private static final DateFormat signatureDateFormat = new SimpleDateFormat("EEE MMM dd HH:mm:ss yyyy z");
private static final long MAX_VALID_MANIFEST_SIZE = 100 * 1024;
private static final String SHA256SUMS_MANIFEST_PREFIX = "sha256sums";
private static final List<String> SIGNATURE_EXTENSIONS = List.of("asc", "sig", "gpg");
private static final List<String> MANIFEST_EXTENSIONS = List.of("txt");
private static final List<String> PUBLIC_KEY_EXTENSIONS = List.of("asc");
private static final List<String> MACOS_RELEASE_EXTENSIONS = List.of("dmg");
private static final List<String> WINDOWS_RELEASE_EXTENSIONS = List.of("exe", "msi", "zip");
private static final List<String> LINUX_RELEASE_EXTENSIONS = List.of("deb", "rpm", "tar.gz");
private static final List<String> DISK_IMAGE_EXTENSIONS = List.of("img", "bin", "dfu");
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
private static final String SPARROW_RELEASE_ALT_PREFIX = "sparrow_";
private static final String SPARROW_MANIFEST_SUFFIX = "-manifest.txt";
private static final String SPARROW_SIGNATURE_SUFFIX = SPARROW_MANIFEST_SUFFIX + ".asc";
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
private static final long MIN_VALID_SPARROW_RELEASE_SIZE = 10 * 1024 * 1024;
private final ObjectProperty<File> signature = new SimpleObjectProperty<>();
private final ObjectProperty<File> manifest = new SimpleObjectProperty<>();
private final ObjectProperty<File> publicKey = new SimpleObjectProperty<>();
private final ObjectProperty<File> release = new SimpleObjectProperty<>();
private final ObjectProperty<File> initial = new SimpleObjectProperty<>();
private final BooleanProperty manifestDisabled = new SimpleBooleanProperty();
private final BooleanProperty publicKeyDisabled = new SimpleBooleanProperty();
private final Label signedBy;
private final Label releaseHash;
private final Label releaseVerified;
private final Hyperlink releaseLink;
private static File lastFileParent;
public DownloadVerifierDialog(File initialFile) {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeader(new Header());
setupDrag(dialogPane);
VBox vBox = new VBox();
vBox.setSpacing(20);
vBox.setPadding(new Insets(20, 10, 10, 20));
Form form = new Form();
Fieldset filesFieldset = new Fieldset();
filesFieldset.setText("Files");
filesFieldset.setSpacing(10);
String version = VersionCheckService.getVersion() != null ? VersionCheckService.getVersion() : "x.x.x";
Field signatureField = setupField(signature, "Signature", SIGNATURE_EXTENSIONS, false, "sparrow-" + version + "-manifest.txt", null);
Field manifestField = setupField(manifest, "Manifest", MANIFEST_EXTENSIONS, false, "sparrow-" + version + "-manifest", manifestDisabled);
Field publicKeyField = setupField(publicKey, "Public Key", PUBLIC_KEY_EXTENSIONS, true, "pgp_keys", publicKeyDisabled);
Field releaseFileField = setupField(release, "Release File", getReleaseFileExtensions(), false, getReleaseFileExample(version), null);
filesFieldset.getChildren().addAll(signatureField, manifestField, publicKeyField, releaseFileField);
form.getChildren().add(filesFieldset);
Fieldset resultsFieldset = new Fieldset();
resultsFieldset.setText("Results");
resultsFieldset.setSpacing(10);
signedBy = new Label();
Field signedByField = setupResultField(signedBy, "Signed By");
releaseHash = new Label();
Field hashMatchedField = setupResultField(releaseHash, "Release Hash");
releaseVerified = new Label();
Field releaseVerifiedField = setupResultField(releaseVerified, "Verified");
releaseLink = new Hyperlink("");
releaseVerifiedField.getInputs().add(releaseLink);
releaseLink.setOnAction(event -> {
if(release.get() != null && release.get().exists()) {
if(release.get().getName().toLowerCase(Locale.ROOT).startsWith("sparrow")) {
Optional<ButtonType> optType = AppServices.showAlertDialog("Exit Sparrow?", "Sparrow must be closed before installation. Exit?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.YES) {
javafx.application.Platform.exit();
AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath());
}
} else {
AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath());
}
}
});
resultsFieldset.getChildren().addAll(signedByField, hashMatchedField, releaseVerifiedField);
form.getChildren().add(resultsFieldset);
vBox.getChildren().addAll(form);
dialogPane.setContent(vBox);
ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear", ButtonBar.ButtonData.CANCEL_CLOSE);
ButtonType closeButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(clearButtonType, closeButtonType);
setOnCloseRequest(event -> {
if(ButtonBar.ButtonData.CANCEL_CLOSE.equals(getResult())) {
signature.set(null);
manifest.set(null);
publicKey.set(null);
release.set(null);
signedBy.setText("");
signedBy.setGraphic(null);
signedBy.setTooltip(null);
releaseHash.setText("");
releaseHash.setGraphic(null);
releaseVerified.setText("");
releaseVerified.setGraphic(null);
releaseLink.setText("");
event.consume();
}
});
setResultConverter(ButtonType::getButtonData);
AppServices.moveToActiveWindowScreen(this);
dialogPane.setPrefWidth(900);
setResizable(true);
signature.addListener((observable, oldValue, signatureFile) -> {
if(signatureFile != null) {
boolean verify = true;
File actualSignatureFile = findSignatureFile(signatureFile);
if(actualSignatureFile != null && !actualSignatureFile.equals(signature.get())) {
signature.set(actualSignatureFile);
verify = false;
} else if(PGPUtils.signatureContainsManifest(signatureFile)) {
manifest.set(signatureFile);
verify = false;
} else {
File manifestFile = findManifestFile(signatureFile);
if(manifestFile != null && !manifestFile.equals(manifest.get())) {
manifest.set(manifestFile);
verify = false;
}
}
if(verify) {
verify();
}
}
});
manifest.addListener((observable, oldValue, manifestFile) -> {
if(manifestFile != null) {
boolean verify = true;
try {
Map<File, String> manifestMap = getManifest(manifestFile);
File releaseFile = findReleaseFile(manifestFile, manifestMap);
if(releaseFile != null && !releaseFile.equals(release.get())) {
release.set(releaseFile);
verify = false;
}
} catch(IOException e) {
log.debug("Error reading manifest file", e);
verify = false;
} catch(InvalidManifestException e) {
release.set(manifestFile);
verify = false;
}
if(verify) {
verify();
}
}
});
publicKey.addListener((observable, oldValue, newValue) -> {
verify();
});
release.addListener((observable, oldValue, releaseFile) -> {
if(releaseFile != null) {
initial.set(null);
}
verify();
});
if(initialFile != null) {
javafx.application.Platform.runLater(() -> {
initial.set(initialFile);
signature.set(initialFile);
});
}
}
private void setupDrag(DialogPane dialogPane) {
dialogPane.setOnDragOver(event -> {
if(event.getGestureSource() != dialogPane && event.getDragboard().hasFiles()) {
event.acceptTransferModes(TransferMode.LINK);
}
event.consume();
});
dialogPane.setOnDragDropped(event -> {
Dragboard db = event.getDragboard();
boolean success = false;
if(db.hasFiles()) {
for(File file : db.getFiles()) {
if(isVerifyDownloadFile(file)) {
signature.set(file);
break;
}
}
success = true;
}
event.setDropCompleted(success);
event.consume();
});
dialogPane.setOnDragEntered(event -> {
dialogPane.getStyleClass().add(DRAG_OVER_CLASS);
});
dialogPane.setOnDragExited(event -> {
dialogPane.getStyleClass().removeAll(DRAG_OVER_CLASS);
});
}
private void verify() {
manifestDisabled.set(false);
publicKeyDisabled.set(false);
if(signature.get() == null || manifest.get() == null) {
clearReleaseFields();
return;
}
PGPVerifyService pgpVerifyService = new PGPVerifyService(signature.get(), manifest.get(), publicKey.get());
pgpVerifyService.setOnRunning(event -> {
signedBy.setText("Verifying...");
signedBy.setGraphic(GlyphUtils.getBusyGlyph());
signedBy.setTooltip(null);
clearReleaseFields();
});
pgpVerifyService.setOnSucceeded(event -> {
PGPVerificationResult result = pgpVerifyService.getValue();
String message = result.userId() + " on " + signatureDateFormat.format(result.signatureTimestamp()) + (result.expired() ? " (key expired)" : "");
signedBy.setText(message);
signedBy.setGraphic(result.expired() ? GlyphUtils.getWarningGlyph() : GlyphUtils.getSuccessGlyph());
signedBy.setTooltip(new Tooltip(result.fingerprint()));
if(!result.expired() && result.keySource() != PGPKeySource.USER) {
publicKeyDisabled.set(true);
}
if(manifest.get().equals(release.get()) && !isSparrowManifest(manifest.get())) {
manifestDisabled.set(true);
releaseHash.setText("No hash required, signature signs release file directly");
releaseHash.setGraphic(GlyphUtils.getSuccessGlyph());
releaseHash.setTooltip(null);
releaseVerified.setText("Ready to install ");
releaseVerified.setGraphic(GlyphUtils.getSuccessGlyph());
releaseLink.setText(release.get().getName());
} else {
verifyManifest();
}
});
pgpVerifyService.setOnFailed(event -> {
Throwable e = event.getSource().getException();
signedBy.setText(getDisplayMessage(e));
signedBy.setGraphic(GlyphUtils.getFailureGlyph());
signedBy.setTooltip(null);
clearReleaseFields();
});
pgpVerifyService.start();
}
private void clearReleaseFields() {
releaseHash.setText("");
releaseHash.setGraphic(null);
releaseHash.setTooltip(null);
releaseVerified.setText("");
releaseVerified.setGraphic(null);
releaseLink.setText("");
}
private void verifyManifest() {
File releaseFile = release.get();
if(releaseFile != null && releaseFile.exists()) {
FileSha256Service hashService = new FileSha256Service(releaseFile);
hashService.setOnRunning(event -> {
releaseHash.setText("Calculating...");
releaseHash.setGraphic(GlyphUtils.getBusyGlyph());
releaseHash.setTooltip(null);
releaseVerified.setText("");
releaseVerified.setGraphic(null);
releaseLink.setText("");
});
hashService.setOnSucceeded(event -> {
String calculatedHash = hashService.getValue();
try {
Map<File, String> manifestMap = getManifest(manifest.get());
String manifestHash = getManifestHash(releaseFile.getName(), manifestMap);
if(calculatedHash.equalsIgnoreCase(manifestHash)) {
releaseHash.setText("Matched manifest hash");
releaseHash.setGraphic(GlyphUtils.getSuccessGlyph());
releaseHash.setTooltip(new Tooltip(calculatedHash));
releaseVerified.setText("Ready to install ");
releaseVerified.setGraphic(GlyphUtils.getSuccessGlyph());
releaseLink.setText(releaseFile.getName());
} else if(manifestHash == null) {
releaseHash.setText("Could not find manifest hash for " + releaseFile.getName());
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip("Manifest hashes provided for:\n" + manifestMap.keySet().stream().map(File::getName).collect(Collectors.joining("\n"))));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
} else {
releaseHash.setText("Did not match manifest hash");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip("Calculated Hash: " + calculatedHash + "\nManifest Hash: " + manifestHash));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
}
} catch(IOException | InvalidManifestException e) {
releaseHash.setText("Could not read manifest");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip(e.getMessage()));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
}
});
hashService.setOnFailed(event -> {
releaseHash.setText("Could not calculate manifest");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip(event.getSource().getException().getMessage()));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
});
hashService.start();
} else {
releaseHash.setText("No release file");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(null);
releaseVerified.setText("Not verified");
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
}
}
private Field setupField(ObjectProperty<File> fileProperty, String title, List<String> extensions, boolean optional, String example, BooleanProperty disabledProperty) {
Field field = new Field();
field.setText(title + ":");
FileField fileField = new FileField(fileProperty, title, extensions, optional, example, disabledProperty);
field.getInputs().add(fileField);
return field;
}
private Field setupResultField(Label label, String title) {
Field field = new Field();
field.setText(title + ":");
field.getInputs().add(label);
label.setGraphicTextGap(8);
return field;
}
public static Map<File, String> getManifest(File manifest) throws IOException, InvalidManifestException {
if(manifest.length() > MAX_VALID_MANIFEST_SIZE) {
throw new InvalidManifestException();
}
try(InputStream manifestStream = new FileInputStream(manifest)) {
return getManifest(manifestStream);
}
}
public static Map<File, String> getManifest(InputStream manifestStream) throws IOException {
Map<File, String> manifest = new HashMap<>();
BufferedReader reader = new BufferedReader(new InputStreamReader(manifestStream, StandardCharsets.UTF_8));
String line;
while((line = reader.readLine()) != null) {
String[] parts = line.split("\\s+");
if(parts.length > 1 && parts[0].length() == 64) {
String manifestHash = parts[0];
String manifestFileName = parts[1];
if(manifestFileName.startsWith("*") || manifestFileName.startsWith("U") || manifestFileName.startsWith("^")) {
manifestFileName = manifestFileName.substring(1);
}
manifest.put(new File(manifestFileName), manifestHash);
}
}
return manifest;
}
private String getManifestHash(String contentFileName, Map<File, String> manifest) {
for(Map.Entry<File, String> entry : manifest.entrySet()) {
if(contentFileName.equalsIgnoreCase(entry.getKey().getName())) {
return entry.getValue();
}
}
return null;
}
private File findSignatureFile(File providedFile) {
for(String extension : SIGNATURE_EXTENSIONS) {
File signatureFile = new File(providedFile.getParentFile(), providedFile.getName() + "." + extension);
if(signatureFile.exists()) {
return signatureFile;
}
}
String providedName = providedFile.getName().toLowerCase(Locale.ROOT);
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || providedName.startsWith(SPARROW_RELEASE_ALT_PREFIX)) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
if(matcher.find()) {
String version = matcher.group();
File signatureFile = new File(providedFile.getParentFile(), SPARROW_RELEASE_PREFIX + version + SPARROW_SIGNATURE_SUFFIX);
if(signatureFile.exists()) {
return signatureFile;
}
}
}
return null;
}
private File findManifestFile(File providedFile) {
String signatureName = providedFile.getName();
if(signatureName.length() > 4 && SIGNATURE_EXTENSIONS.stream().anyMatch(ext -> signatureName.toLowerCase(Locale.ROOT).endsWith("." + ext))) {
File manifestFile = new File(providedFile.getParent(), signatureName.substring(0, signatureName.length() - 4));
if(manifestFile.exists()) {
return manifestFile;
}
}
return null;
}
private File findReleaseFile(File manifestFile, Map<File, String> manifestMap) {
File initialFile = initial.get();
if(initialFile != null && initialFile.exists()) {
for(File file : manifestMap.keySet()) {
if(initialFile.getName().equals(file.getName())) {
return initialFile;
}
}
List<List<String>> allExtensionLists = List.of(MACOS_RELEASE_EXTENSIONS, WINDOWS_RELEASE_EXTENSIONS, LINUX_RELEASE_EXTENSIONS, DISK_IMAGE_EXTENSIONS, ARCHIVE_EXTENSIONS);
for(List<String> extensions : allExtensionLists) {
if(extensions.stream().anyMatch(ext -> initialFile.getName().toLowerCase(Locale.ROOT).endsWith(ext))) {
return initialFile;
}
}
}
List<String> releaseExtensions = getReleaseFileExtensions();
List<List<String>> extensionLists = List.of(releaseExtensions, DISK_IMAGE_EXTENSIONS, ARCHIVE_EXTENSIONS, List.of(""));
for(List<String> extensions : extensionLists) {
for(File file : manifestMap.keySet()) {
if(extensions.stream().anyMatch(ext -> file.getName().toLowerCase(Locale.ROOT).endsWith(ext))) {
File releaseFile = new File(manifestFile.getParent(), file.getName());
if(releaseFile.exists()) {
return releaseFile;
}
}
}
}
return null;
}
private List<String> getReleaseFileExtensions() {
OsType osType = OsType.getCurrent();
switch(osType) {
case MACOS -> {
return MACOS_RELEASE_EXTENSIONS;
}
case WINDOWS -> {
return WINDOWS_RELEASE_EXTENSIONS;
}
default -> {
return LINUX_RELEASE_EXTENSIONS;
}
}
}
private String getReleaseFileExample(String version) {
OsType osType = OsType.getCurrent();
String arch = System.getProperty("os.arch");
switch(osType) {
case MACOS -> {
return "Sparrow-" + version + "-" + arch;
}
case WINDOWS -> {
return "Sparrow-" + version;
}
default -> {
return "sparrow_" + version + "-1_" + (arch.equals("aarch64") ? "arm64" : arch);
}
}
}
private String getDisplayMessage(Throwable e) {
String message = e.getMessage();
message = message.substring(0, 1).toUpperCase(Locale.ROOT) + message.substring(1);
if(message.endsWith(".")) {
message = message.substring(0, message.length() - 1);
}
if(message.equals("Invalid header encountered")) {
message += ", not a valid signature file";
}
if(message.startsWith("Malformed message")) {
message = "Not a valid signature file";
}
return message;
}
public static boolean isVerifyDownloadFile(File file) {
if(file != null) {
String name = file.getName().toLowerCase(Locale.ROOT);
if(name.length() > 4 && SIGNATURE_EXTENSIONS.stream().anyMatch(ext -> name.endsWith("." + ext))) {
return true;
}
if(MANIFEST_EXTENSIONS.stream().anyMatch(ext -> name.endsWith("." + ext)) || name.startsWith(SHA256SUMS_MANIFEST_PREFIX)) {
try {
Map<File, String> manifest = getManifest(file);
return !manifest.isEmpty();
} catch(Exception e) {
//ignore
}
}
if((name.startsWith(SPARROW_RELEASE_PREFIX) || name.startsWith(SPARROW_RELEASE_ALT_PREFIX)) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
}
return false;
}
public static boolean isSparrowManifest(File manifestFile) {
return manifestFile.getName().startsWith(SPARROW_RELEASE_PREFIX) && manifestFile.getName().endsWith(SPARROW_MANIFEST_SUFFIX);
}
public void setSignatureFile(File signatureFile) {
signature.set(signatureFile);
}
public void setInitialFile(File initialFile) {
initial.set(initialFile);
}
private static class Header extends GridPane {
public Header() {
setMaxWidth(Double.MAX_VALUE);
getStyleClass().add("header-panel");
VBox vBox = new VBox();
vBox.setPadding(new Insets(10, 0, 0, 0));
Label headerLabel = new Label("Verify Download");
headerLabel.setWrapText(true);
headerLabel.setAlignment(Pos.CENTER_LEFT);
headerLabel.setMaxWidth(Double.MAX_VALUE);
headerLabel.setMaxHeight(Double.MAX_VALUE);
CopyableLabel descriptionLabel = new CopyableLabel("Download the release file, GPG signature and optional manifest of a project to verify the download integrity");
descriptionLabel.setAlignment(Pos.CENTER_LEFT);
vBox.getChildren().addAll(headerLabel, descriptionLabel);
add(vBox, 0, 0);
StackPane graphicContainer = new DialogImage(DialogImage.Type.SPARROW);
graphicContainer.getStyleClass().add("graphic-container");
add(graphicContainer, 1, 0);
ColumnConstraints textColumn = new ColumnConstraints();
textColumn.setFillWidth(true);
textColumn.setHgrow(Priority.ALWAYS);
ColumnConstraints graphicColumn = new ColumnConstraints();
graphicColumn.setFillWidth(false);
graphicColumn.setHgrow(Priority.NEVER);
getColumnConstraints().setAll(textColumn , graphicColumn);
}
}
private static class FileField extends HBox {
private final ObjectProperty<File> fileProperty;
public FileField(ObjectProperty<File> fileProperty, String title, List<String> extensions, boolean optional, String example, BooleanProperty disabledProperty) {
super(10);
this.fileProperty = fileProperty;
TextField textField = new TextField();
textField.setEditable(false);
textField.setPromptText("e.g. " + example + formatExtensionsList(extensions) + (optional ? " (optional)" : ""));
textField.setOnMouseClicked(event -> browseForFile(title, extensions));
Button browseButton = new Button("Browse...");
browseButton.setOnAction(event -> browseForFile(title, extensions));
getChildren().addAll(textField, browseButton);
HBox.setHgrow(textField, Priority.ALWAYS);
fileProperty.addListener((observable, oldValue, file) -> {
textField.setText(file == null ? "" : file.getAbsolutePath());
if(file != null) {
lastFileParent = file.getParentFile();
}
});
if(disabledProperty != null) {
disabledProperty.addListener((observable, oldValue, disabled) -> {
textField.setDisable(disabled);
browseButton.setDisable(disabled);
});
}
}
private void browseForFile(String title, List<String> extensions) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open File");
File userDir = new File(System.getProperty("user.home"));
File downloadsDir = new File(userDir, "Downloads");
fileChooser.setInitialDirectory(lastFileParent != null ? lastFileParent : (downloadsDir.exists() ? downloadsDir : userDir));
fileChooser.setSelectedExtensionFilter(new FileChooser.ExtensionFilter(title + " files", extensions));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
fileProperty.set(file);
}
}
public String formatExtensionsList(List<String> items) {
StringBuilder result = new StringBuilder();
for(int i = 0; i < items.size(); i++) {
result.append(".").append(items.get(i));
if (i < items.size() - 1) {
result.append(", ");
}
if (i == items.size() - 2) {
result.append("or ");
}
}
return result.toString();
}
}
private static class PGPVerifyService extends Service<PGPVerificationResult> {
private final File signature;
private final File manifest;
private final File publicKey;
public PGPVerifyService(File signature, File manifest, File publicKey) {
this.signature = signature;
this.manifest = manifest;
this.publicKey = publicKey;
}
@Override
protected Task<PGPVerificationResult> createTask() {
return new Task<>() {
protected PGPVerificationResult call() throws IOException, PGPVerificationException {
boolean detachedSignature = !manifest.equals(signature);
try(InputStream publicKeyStream = publicKey == null ? null : new FileInputStream(publicKey);
InputStream contentStream = new BufferedInputStream(new FileInputStream(manifest));
InputStream detachedSignatureStream = detachedSignature ? new FileInputStream(signature) : null) {
return PGPUtils.verify(publicKeyStream, contentStream, detachedSignatureStream);
}
}
};
}
}
private static class FileSha256Service extends Service<String> {
private final File file;
public FileSha256Service(File file) {
this.file = file;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
protected String call() throws IOException {
try(InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
return sha256(inputStream);
}
}
};
}
private String sha256(InputStream stream) throws IOException {
try {
final byte[] buffer = new byte[1024 * 1024];
final MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
int bytesRead = 0;
while((bytesRead = stream.read(buffer)) >= 0) {
if (bytesRead > 0) {
sha256.update(buffer, 0, bytesRead);
}
}
return Utils.bytesToHex(sha256.digest());
} catch(NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
private static class InvalidManifestException extends Exception { }
}

View file

@ -1,9 +1,12 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
@ -12,6 +15,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*; import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
@ -30,14 +35,16 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class EntryCell extends TreeTableCell<Entry, Entry> { public class EntryCell extends TreeTableCell<Entry, Entry> implements ConfirmationsListener {
private static final Logger log = LoggerFactory.getLogger(EntryCell.class); private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm"); public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*)\\(Replaced By Fee( #)?(\\d+)?\\).*"); public static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*?)( \\(Replaced By Fee( #)?(\\d+)?\\)).*?");
private static EntryCell lastCell; private static EntryCell lastCell;
private IntegerProperty confirmationsProperty;
public EntryCell() { public EntryCell() {
super(); super();
setAlignment(Pos.CENTER_LEFT); setAlignment(Pos.CENTER_LEFT);
@ -50,7 +57,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
super.updateItem(entry, empty); super.updateItem(entry, empty);
//Return immediately to avoid CPU usage when updating the same invisible cell to determine tableview size (see https://bugs.openjdk.org/browse/JDK-8280442) //Return immediately to avoid CPU usage when updating the same invisible cell to determine tableview size (see https://bugs.openjdk.org/browse/JDK-8280442)
if(this == lastCell && !getTableRow().isVisible()) { if(this == lastCell && !getTableRow().isVisible() && isTableSizeRecalculation()) {
return; return;
} }
lastCell = this; lastCell = this;
@ -61,8 +68,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
setText(null); setText(null);
setGraphic(null); setGraphic(null);
} else { } else {
if(entry instanceof TransactionEntry) { if(entry instanceof TransactionEntry transactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(transactionEntry.getBlockTransaction().getHeight() == -1) { if(transactionEntry.getBlockTransaction().getHeight() == -1) {
setText("Unconfirmed Parent"); setText("Unconfirmed Parent");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry)); setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
@ -96,7 +102,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(viewTransactionButton); actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction(); BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction, transactionEntry.getWallet()) &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button(""); Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph()); increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
@ -116,11 +122,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
} }
setGraphic(actionBox); setGraphic(actionBox);
} else if(entry instanceof NodeEntry) { } else if(entry instanceof NodeEntry nodeEntry) {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress(); Address address = nodeEntry.getAddress();
setText(address.toString()); setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true)); setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
Tooltip tooltip = new Tooltip(); Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250)); tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(nodeEntry.getNode().toString()); tooltip.setText(nodeEntry.getNode().toString());
@ -145,6 +150,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
signMessageButton.setGraphic(getSignMessageGlyph()); signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> { signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode()); MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(getTreeTableView().getScene().getWindow());
messageSignDialog.showAndWait(); messageSignDialog.showAndWait();
}); });
actionBox.getChildren().add(signMessageButton); actionBox.getChildren().add(signMessageButton);
@ -157,8 +163,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
setContextMenu(null); setContextMenu(null);
setGraphic(new HBox()); setGraphic(new HBox());
} }
} else if(entry instanceof HashIndexEntry) { } else if(entry instanceof HashIndexEntry hashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
setText(hashIndexEntry.getDescription()); setText(hashIndexEntry.getDescription());
setContextMenu(getTreeTableView().getStyleClass().contains("bip47") ? null : new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry)); setContextMenu(getTreeTableView().getStyleClass().contains("bip47") ? null : new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry));
Tooltip tooltip = new Tooltip(); Tooltip tooltip = new Tooltip();
@ -189,15 +194,31 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
} }
} }
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) { private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction(); BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
boolean silentPaymentTransaction = transactionEntry.getWallet().isSilentPaymentsTransaction(blockTransaction);
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos(); Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream() List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry) .filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e) .map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable()) .filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex())) .map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(TransactionInput::isReplaceByFeeEnabled) .filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled() || silentPaymentTransaction)
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get()) .map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -211,31 +232,48 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.filter(e -> e instanceof HashIndexEntry) .filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e) .map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT)) .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex())) .map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream() List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry) .filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e) .map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE) .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE)
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex())) .map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList()); .collect(Collectors.toList());
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
boolean safeToAddInputsOrOutputs = transactionEntry.getWallet().isSafeToAddInputsOrOutputs(blockTransaction);
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum(); long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction(); Transaction tx = blockTransaction.getTransaction();
double vSize = tx.getVirtualSize(); double vSize = tx.getVirtualSize();
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0); if(changeTotal == 0) {
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet()); //Add change output length to vSize if change was not present on the original transaction
//Remove any UTXOs created by the transaction that is to be replaced TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getOutputScript());
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex())); vSize += changeOutput.getLength();
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
//If there is insufficient change output, include another random UTXO so the fee can be increased
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
utxos.add(utxo);
changeTotal += utxo.getValue();
vSize += inputSize;
} }
double inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? (double)tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(utxos), new SpentTxoFilter(blockTransaction.getHash()), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction && safeToAddInputsOrOutputs) {
//If there is insufficient change output, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
utxos.add(utxo);
changeTotal += utxo.getValue();
vSize += inputSize;
}
}
Long fee = blockTransaction.getFee();
if(fee != null) {
//Replacement tx fees must be greater than the original tx fees by its minimum relay cost
fee += (long)Math.ceil(vSize * AppServices.getMinimumRelayFeeRate());
}
Long rbfFee = fee;
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs()); List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
externalOutputs.removeAll(ourOutputs); externalOutputs.removeAll(ourOutputs);
@ -244,22 +282,30 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<Payment> payments = externalOutputs.stream().map(txOutput -> { List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try { try {
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel(); String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(label); label = REPLACED_BY_FEE_SUFFIX.matcher(label).replaceAll("$1");
String[] paymentLabels = label.split(", ");
if(externalOutputs.size() > 1 && externalOutputs.size() == paymentLabels.length) {
label = paymentLabels[externalOutputs.indexOf(txOutput)];
}
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel());
if(matcher.matches()) { if(matcher.matches()) {
String base = matcher.group(1); if(matcher.groupCount() > 3 && matcher.group(4) != null) {
if(matcher.groupCount() > 2 && matcher.group(3) != null) { int count = Integer.parseInt(matcher.group(4)) + 1;
int count = Integer.parseInt(matcher.group(3)) + 1; label += " (Replaced By Fee #" + count + ")";
label = base + "(Replaced By Fee #" + count + ")";
} else { } else {
label = base + "(Replaced By Fee #2)"; label += " (Replaced By Fee #2)";
} }
} else { } else {
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)"; label += " (Replaced By Fee)";
} }
if(txOutput.getScript().getToAddress() != null) { Address address = txOutput.getScript().getToAddress();
if(address != null) {
long value = txOutput.getValue();
//Disable change creation by enabling max payment when there is only one output and no additional UTXOs included //Disable change creation by enabling max payment when there is only one output and no additional UTXOs included
return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0); boolean sendMax = blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0;
SilentPaymentAddress silentPaymentAddress = transactionEntry.getWallet().getSilentPaymentAddress(address);
return silentPaymentAddress == null ? new Payment(address, label, value, sendMax) : new SilentPayment(silentPaymentAddress, label, value, sendMax);
} }
return null; return null;
@ -296,7 +342,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
} }
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos)); EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true))); Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction, safeToAddInputsOrOutputs)));
} }
private static Double getMaxFeeRate() { private static Double getMaxFeeRate() {
@ -312,30 +358,57 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream() List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry) .filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e) .map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT)) .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
.map(HashIndexEntry::getHashIndex) .map(HashIndexEntry::getHashIndex)
.collect(Collectors.toList()); .collect(Collectors.toList());
if(ourOutputs.isEmpty()) { if(ourOutputs.isEmpty()) {
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend"); AppServices.showErrorDialog("No spendable outputs", "None of the outputs on this transaction are spendable.\n\nEnsure that the outputs are not frozen" +
(transactionEntry.getConfirmations() <= 0 ? ", and spending unconfirmed UTXOs is allowed." : "."));
return;
} }
BlockTransactionHashIndex utxo = ourOutputs.get(0); BlockTransactionHashIndex cpfpUtxo = ourOutputs.get(0);
Address freshAddress = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress();
TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), freshAddress.getOutputScript());
long dustThreshold = freshAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
double inputSize = freshAddress.getScriptType().getInputVbytes();
double vSize = inputSize + txOutput.getLength();
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(List.of(cpfpUtxo)), new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
List<BlockTransactionHashIndex> utxos = new ArrayList<>();
utxos.add(cpfpUtxo);
long inputTotal = cpfpUtxo.getValue();
while((inputTotal - (long)(getMaxFeeRate() * vSize)) < dustThreshold && !outputGroups.isEmpty()) {
//If there is insufficient input value, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
utxos.add(utxo);
inputTotal += utxo.getValue();
vSize += inputSize;
}
}
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel(); String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)"; label += (label.isEmpty() ? "" : " ") + "(CPFP)";
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true); Payment payment = new Payment(freshAddress, label, inputTotal, true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo))); EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false))); Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null, true)));
}
private static boolean canRBF(BlockTransaction blockTransaction, Wallet wallet) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee() || wallet.isSilentPaymentsTransaction(blockTransaction);
} }
private static boolean canSignMessage(WalletNode walletNode) { private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet(); Wallet wallet = walletNode.getWallet();
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR && return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB) &&
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
} }
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) { private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
@ -392,7 +465,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Double feeRate = transactionEntry.getBlockTransaction().getFeeRate(); Double feeRate = transactionEntry.getBlockTransaction().getFeeRate();
Long vSizefromTip = transactionEntry.getVSizeFromTip(); Long vSizefromTip = transactionEntry.getVSizeFromTip();
if(feeRate != null && vSizefromTip != null) { if(feeRate != null && vSizefromTip != null) {
long blocksFromTip = (long)Math.ceil((double)vSizefromTip / Transaction.MAX_BLOCK_SIZE); long blocksFromTip = (long)Math.ceil((double)vSizefromTip / Transaction.MAX_BLOCK_SIZE_VBYTES);
String amount = vSizefromTip + " vB"; String amount = vSizefromTip + " vB";
if(vSizefromTip > 1000 * 1000) { if(vSizefromTip > 1000 * 1000) {
@ -408,7 +481,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB"; tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
} }
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled"); tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction(), transactionEntry.getWallet()) ? "Enabled" : "Disabled");
} }
return tooltip; return tooltip;
@ -476,6 +549,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
private static class UnconfirmedTransactionContextMenu extends ContextMenu { private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) { public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
Wallet wallet = transactionEntry.getWallet();
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction(); BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
MenuItem viewTransaction = new MenuItem("View Transaction"); MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph()); viewTransaction.setGraphic(getViewTransactionGlyph());
@ -485,7 +559,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}); });
getItems().add(viewTransaction); getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)"); MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph()); increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> { increaseFee.setOnAction(AE -> {
@ -496,7 +570,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(increaseFee); getItems().add(increaseFee);
} }
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) { if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)"); MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph()); cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> { cancelTx.setOnAction(AE -> {
@ -518,6 +592,15 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(createCpfp); getItems().add(createCpfp);
} }
if(!Config.get().isBlockExplorerDisabled()) {
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
}
MenuItem copyTxid = new MenuItem("Copy Transaction ID"); MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> { copyTxid.setOnAction(AE -> {
hide(); hide();
@ -530,7 +613,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
} }
} }
private static class TransactionContextMenu extends ContextMenu { protected static class TransactionContextMenu extends ContextMenu {
public TransactionContextMenu(String date, BlockTransaction blockTransaction) { public TransactionContextMenu(String date, BlockTransaction blockTransaction) {
MenuItem viewTransaction = new MenuItem("View Transaction"); MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph()); viewTransaction.setGraphic(getViewTransactionGlyph());
@ -538,6 +621,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
hide(); hide();
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction)); EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
}); });
getItems().add(viewTransaction);
if(!Config.get().isBlockExplorerDisabled()) {
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
}
MenuItem copyDate = new MenuItem("Copy Date"); MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> { copyDate.setOnAction(AE -> {
@ -546,6 +639,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
content.putString(date); content.putString(date);
Clipboard.getSystemClipboard().setContent(content); Clipboard.getSystemClipboard().setContent(content);
}); });
getItems().add(copyDate);
MenuItem copyTxid = new MenuItem("Copy Transaction ID"); MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> { copyTxid.setOnAction(AE -> {
@ -554,6 +648,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
content.putString(blockTransaction.getHashAsString()); content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content); Clipboard.getSystemClipboard().setContent(content);
}); });
getItems().add(copyTxid);
MenuItem copyHeight = new MenuItem("Copy Block Height"); MenuItem copyHeight = new MenuItem("Copy Block Height");
copyHeight.setOnAction(AE -> { copyHeight.setOnAction(AE -> {
@ -562,13 +657,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
content.putString(blockTransaction.getHeight() > 0 ? Integer.toString(blockTransaction.getHeight()) : "Mempool"); content.putString(blockTransaction.getHeight() > 0 ? Integer.toString(blockTransaction.getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content); Clipboard.getSystemClipboard().setContent(content);
}); });
getItems().add(copyHeight);
getItems().addAll(viewTransaction, copyDate, copyTxid, copyHeight);
} }
} }
public static class AddressContextMenu extends ContextMenu { public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems) { public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems, TreeTableView<Entry> treetable) {
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) { if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
MenuItem receiveToAddress = new MenuItem("Receive To"); MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph()); receiveToAddress.setGraphic(getReceiveGlyph());
@ -586,6 +680,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
signVerifyMessage.setOnAction(AE -> { signVerifyMessage.setOnAction(AE -> {
hide(); hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode()); MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(treetable.getScene().getWindow());
messageSignDialog.showAndWait(); messageSignDialog.showAndWait();
}); });
getItems().add(signVerifyMessage); getItems().add(signVerifyMessage);
@ -635,14 +730,6 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content); Clipboard.getSystemClipboard().setContent(content);
}); });
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
copyHex.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(Utils.bytesToHex(address.getOutputScriptData()));
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor"); MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
copyOutputDescriptor.setOnAction(AE -> { copyOutputDescriptor.setOnAction(AE -> {
hide(); hide();
@ -651,11 +738,23 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content); Clipboard.getSystemClipboard().setContent(content);
}); });
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor); getItems().addAll(copyAddress, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
copyHex.setOnAction(AE -> {
hide();
Script outputScript = nodeEntry.getWallet().getOutputScript(nodeEntry.getNode());
ClipboardContent content = new ClipboardContent();
content.putString(Utils.bytesToHex(outputScript.getProgram()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyHex);
}
} }
} }
private static class HashIndexEntryContextMenu extends ContextMenu { static class HashIndexEntryContextMenu extends ContextMenu {
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) { public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
MenuItem viewTransaction = new MenuItem("View Transaction"); MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph()); viewTransaction.setGraphic(getViewTransactionGlyph());
@ -711,40 +810,57 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
cell.getStyleClass().remove("transaction-row"); cell.getStyleClass().remove("transaction-row");
cell.getStyleClass().remove("node-row"); cell.getStyleClass().remove("node-row");
cell.getStyleClass().remove("utxo-row"); cell.getStyleClass().remove("utxo-row");
cell.getStyleClass().remove("address-cell"); cell.getStyleClass().remove("unconfirmed-row");
cell.getStyleClass().remove("summary-row");
boolean addressCell = cell.getStyleClass().remove("address-cell");
cell.getStyleClass().remove("hashindex-row"); cell.getStyleClass().remove("hashindex-row");
cell.getStyleClass().remove("confirming"); cell.getStyleClass().remove("confirming");
cell.getStyleClass().remove("negative-amount"); cell.getStyleClass().remove("negative-amount");
cell.getStyleClass().remove("spent"); cell.getStyleClass().remove("spent");
cell.getStyleClass().remove("unspendable"); cell.getStyleClass().remove("unspendable");
cell.getStyleClass().remove("number-field");
if(entry != null) { if(entry != null) {
if(entry instanceof TransactionEntry) { if(entry instanceof TransactionEntry transactionEntry) {
cell.getStyleClass().add("transaction-row"); cell.getStyleClass().add("transaction-row");
TransactionEntry transactionEntry = (TransactionEntry)entry; if(cell instanceof ConfirmationsListener confirmationsListener) {
if(transactionEntry.isConfirming()) { if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming"); cell.getStyleClass().add("confirming");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> { confirmationsListener.getConfirmationsProperty().bind(transactionEntry.confirmationsProperty());
if(!transactionEntry.isConfirming()) { } else {
cell.getStyleClass().remove("confirming"); confirmationsListener.getConfirmationsProperty().unbind();
} }
}); }
if(OsType.getCurrent() == OsType.MACOS && transactionEntry.getBlockTransaction().getHeight() > 0 && !cell.getStyleClass().contains("label-cell")) {
cell.getStyleClass().add("number-field");
} }
} else if(entry instanceof NodeEntry) { } else if(entry instanceof NodeEntry) {
cell.getStyleClass().add("node-row"); cell.getStyleClass().add("node-row");
} else if(entry instanceof UtxoEntry) { } else if(entry instanceof UtxoEntry utxoEntry) {
cell.getStyleClass().add("utxo-row"); cell.getStyleClass().add("utxo-row");
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(!utxoEntry.isSpendable()) { if(!utxoEntry.isSpendable()) {
cell.getStyleClass().add("unspendable"); cell.getStyleClass().add("unspendable");
} }
} else if(entry instanceof HashIndexEntry) { if(OsType.getCurrent() == OsType.MACOS && utxoEntry.getHashIndex().getHeight() > 0 && !addressCell && !cell.getStyleClass().contains("label-cell")) {
cell.getStyleClass().add("number-field");
}
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
cell.getStyleClass().add("hashindex-row"); cell.getStyleClass().add("hashindex-row");
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
if(hashIndexEntry.isSpent()) { if(hashIndexEntry.isSpent()) {
cell.getStyleClass().add("spent"); cell.getStyleClass().add("spent");
} }
} else if(entry instanceof WalletSummaryDialog.UnconfirmedEntry) {
cell.getStyleClass().add("unconfirmed-row");
} else if(entry instanceof WalletSummaryDialog.SummaryEntry || entry instanceof WalletSummaryDialog.AllSummaryEntry) {
cell.getStyleClass().add("summary-row");
} }
} }
} }
private boolean isTableSizeRecalculation() {
//As per https://bugs.openjdk.org/browse/JDK-8265669 we check for cell visibility to avoid unnecessary recalculation, but this can result in false positives
//The method releaseCell in VirtualFlow is responsible for setting accumCell visibility to false after use, so check this method is calling updateItem
return StackWalker.getInstance().walk(frames -> frames.anyMatch(frame -> frame.getClassName().equals("javafx.scene.control.skin.VirtualFlow")
&& frame.getMethodName().equals("releaseCell")));
}
} }

View file

@ -0,0 +1,202 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Slider;
import javafx.util.StringConverter;
import java.text.DecimalFormat;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
public class FeeRangeSlider extends Slider {
private static final double FEE_RATE_SCROLL_INCREMENT = 0.01;
private static final DecimalFormat INTEGER_FEE_RATE_FORMAT = new DecimalFormat("0");
private static final DecimalFormat FRACTIONAL_FEE_RATE_FORMAT = new DecimalFormat("0.###");
public FeeRangeSlider() {
super(0, AppServices.getFeeRatesRange().size() - 1, 0);
setMajorTickUnit(1);
setMinorTickCount(0);
setSnapToTicks(false);
setShowTickLabels(true);
setShowTickMarks(true);
setBlockIncrement(Math.log(1.02) / Math.log(2));
setLabelFormatter(new StringConverter<>() {
@Override
public String toString(Double object) {
Double feeRate = AppServices.getLongFeeRatesRange().get(object.intValue());
if(isLongFeeRange() && feeRate >= 1000) {
return INTEGER_FEE_RATE_FORMAT.format(feeRate / 1000) + "k";
}
return feeRate > 0d && feeRate < Transaction.DEFAULT_MIN_RELAY_FEE ? FRACTIONAL_FEE_RATE_FORMAT.format(feeRate) : INTEGER_FEE_RATE_FORMAT.format(feeRate);
}
@Override
public Double fromString(String string) {
return null;
}
});
updateTrackHighlight();
valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
updateMaxFeeRange(newValue.doubleValue());
}
});
setOnScroll(event -> {
if(event.getDeltaY() != 0) {
double newFeeRate = getFeeRate() + (event.getDeltaY() > 0 ? FEE_RATE_SCROLL_INCREMENT : -FEE_RATE_SCROLL_INCREMENT);
if(newFeeRate < AppServices.getLongFeeRatesRange().getFirst()) {
newFeeRate = AppServices.getLongFeeRatesRange().getFirst();
} else if(newFeeRate > AppServices.getLongFeeRatesRange().getLast()) {
newFeeRate = AppServices.getLongFeeRatesRange().getLast();
}
setFeeRate(newFeeRate);
}
});
}
public double getFeeRate() {
return getFeeRate(AppServices.getMinimumRelayFeeRate());
}
public double getFeeRate(Double minRelayFeeRate) {
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return Math.pow(2.0, getValue());
}
if(getValue() < 1.0d) {
if(minRelayFeeRate == 0.0d) {
return getValue();
}
return Math.pow(minRelayFeeRate, 1.0d - getValue());
}
return Math.pow(2.0, getValue() - 1.0d);
}
public void setFeeRate(double feeRate) {
setFeeRate(feeRate, AppServices.getMinimumRelayFeeRate());
}
public void setFeeRate(double feeRate, Double minRelayFeeRate) {
double value = getValue(feeRate, minRelayFeeRate);
updateMaxFeeRange(value);
setValue(value);
}
private double getValue(double feeRate, Double minRelayFeeRate) {
double value;
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
value = Math.log(feeRate) / Math.log(2);
} else {
if(feeRate < Transaction.DEFAULT_MIN_RELAY_FEE) {
if(minRelayFeeRate == 0.0d) {
return feeRate;
}
value = 1.0d - (Math.log(feeRate) / Math.log(minRelayFeeRate));
} else {
value = (Math.log(feeRate) / Math.log(2.0)) + 1.0d;
}
}
return value;
}
public void updateFeeRange(Double minRelayFeeRate, Double previousMinRelayFeeRate) {
if(minRelayFeeRate != null && previousMinRelayFeeRate != null) {
setFeeRate(getFeeRate(previousMinRelayFeeRate), minRelayFeeRate);
}
setMinorTickCount(1);
setMinorTickCount(0);
}
private void updateMaxFeeRange(double value) {
if(value >= getMax() && !isLongFeeRange()) {
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
setMin(1.0d);
}
setMax(AppServices.getLongFeeRatesRange().size() - 1);
updateTrackHighlight();
} else if(value == getMin() && isLongFeeRange()) {
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
setMin(0.0d);
}
setMax(AppServices.getFeeRatesRange().size() - 1);
updateTrackHighlight();
}
}
public boolean isLongFeeRange() {
return getMax() > AppServices.getFeeRatesRange().size() - 1;
}
public void updateTrackHighlight() {
addFeeRangeTrackHighlight(0);
}
private void addFeeRangeTrackHighlight(int count) {
Platform.runLater(() -> {
Node track = lookup(".track");
if(track != null) {
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
String highlight = "";
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
}
highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";
track.setStyle("-fx-background-color: " +
"-fx-shadow-highlight-color, " +
"linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), " +
"linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)), " +
"linear-gradient(to right, " + highlight + ")");
} else if(count < 20) {
addFeeRangeTrackHighlight(count+1);
}
});
}
private Map<Integer, Double> getTargetBlocksFeeRates() {
Map<Integer, Double> retrievedFeeRates = AppServices.getTargetBlockFeeRates();
if(retrievedFeeRates == null) {
retrievedFeeRates = TARGET_BLOCKS_RANGE.stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> getFallbackFeeRate(),
(u, v) -> { throw new IllegalStateException("Duplicate target blocks"); },
LinkedHashMap::new));
}
return retrievedFeeRates;
}
private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
Collections.reverse(rates);
for(Integer targetBlocks : rates) {
if(targetBlocks < minTargetBlocks) {
return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
}
}
return 100;
}
private int getPercentageOfFeeRange(Double feeRate) {
double index = getValue(feeRate, AppServices.getMinimumRelayFeeRate());
if(isLongFeeRange()) {
index *= ((double)AppServices.getFeeRatesRange().size() / (AppServices.getLongFeeRatesRange().size())) * 0.99;
}
return (int)Math.round(index * 10.0);
}
}

View file

@ -0,0 +1,94 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.math.BigDecimal;
import java.util.Currency;
public class FiatCell extends TreeTableCell<Entry, Number> {
private final Tooltip tooltip;
private final FiatContextMenu contextMenu;
public FiatCell() {
super();
tooltip = new Tooltip();
contextMenu = new FiatContextMenu();
getStyleClass().add("coin-cell");
if(OsType.getCurrent() == OsType.MACOS) {
getStyleClass().add("number-field");
}
}
@Override
protected void updateItem(Number amount, boolean empty) {
super.updateItem(amount, empty);
if(empty || amount == null) {
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
} else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
CoinTreeTable coinTreeTable = (CoinTreeTable) getTreeTableView();
UnitFormat format = coinTreeTable.getUnitFormat();
CurrencyRate currencyRate = coinTreeTable.getCurrencyRate();
if(currencyRate != null && currencyRate.isAvailable()) {
Currency currency = currencyRate.getCurrency();
double btcRate = currencyRate.getBtcRate();
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
setText(label);
setGraphic(null);
setTooltip(tooltip);
setContextMenu(contextMenu);
} else {
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
}
}
}
private class FiatContextMenu extends ContextMenu {
public FiatContextMenu() {
MenuItem copyValue = new MenuItem("Copy Value");
copyValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(getText());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyRate = new MenuItem("Copy Rate");
copyRate.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(getTooltip().getText());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyValue, copyRate);
}
}
}

View file

@ -1,11 +1,13 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.google.gson.JsonParseException; import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException; import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.FileImport; import com.sparrowwallet.sparrow.io.FileImport;
@ -24,9 +26,7 @@ import javafx.stage.FileChooser;
import javafx.stage.Stage; import javafx.stage.Stage;
import org.controlsfx.control.SegmentedButton; import org.controlsfx.control.SegmentedButton;
import org.controlsfx.control.textfield.CustomPasswordField; import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -45,8 +45,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private final boolean fileFormatAvailable; private final boolean fileFormatAvailable;
protected List<Wallet> wallets; protected List<Wallet> wallets;
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable, boolean fileFormatAvailable) { public FileImportPane(FileImport importer, String title, String description, String content, WalletModel walletModel, boolean scannable, boolean fileFormatAvailable) {
super(title, description, content, imageUrl); super(title, description, content, walletModel);
this.importer = importer; this.importer = importer;
this.scannable = scannable; this.scannable = scannable;
this.fileFormatAvailable = fileFormatAvailable; this.fileFormatAvailable = fileFormatAvailable;
@ -104,7 +104,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser(); FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File"); fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
fileChooser.getExtensionFilters().addAll( fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", Platform.getCurrent().equals(Platform.UNIX) ? "*" : "*.*"), new FileChooser.ExtensionFilter("All Files", OsType.getCurrent().equals(OsType.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("JSON", "*.json"), new FileChooser.ExtensionFilter("JSON", "*.json"),
new FileChooser.ExtensionFilter("TXT", "*.txt") new FileChooser.ExtensionFilter("TXT", "*.txt")
); );
@ -150,6 +150,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private void importQR() { private void importQR() {
QRScanDialog qrScanDialog = new QRScanDialog(); QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(this.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait(); Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) { if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get(); QRScanDialog.Result result = optionalResult.get();
@ -162,8 +163,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
setError("Import Error", e.getMessage()); setError("Import Error", e.getMessage());
} }
} else if(result.outputDescriptor != null) { } else if(result.outputDescriptor != null) {
wallets = List.of(result.outputDescriptor.toKeystoreWallet(null));
try { try {
wallets = List.of(result.outputDescriptor.toWallet());
importFile(importer.getName(), null, null); importFile(importer.getName(), null, null);
} catch(ImportException e) { } catch(ImportException e) {
log.error("Error importing QR", e); log.error("Error importing QR", e);
@ -193,6 +194,10 @@ public abstract class FileImportPane extends TitledDescriptionPane {
} }
} }
protected List<Wallet> getScannedWallets() {
return wallets;
}
protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException { protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException {
if(wallets != null) { if(wallets != null) {
for(Wallet wallet : wallets) { for(Wallet wallet : wallets) {
@ -215,7 +220,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private Node getPasswordEntry(File file) { private Node getPasswordEntry(File file) {
CustomPasswordField passwordField = new ViewPasswordField(); CustomPasswordField passwordField = new ViewPasswordField();
passwordField.setPromptText("Wallet password"); passwordField.setPromptText("Password");
password.bind(passwordField.textProperty()); password.bind(passwordField.textProperty());
HBox.setHgrow(passwordField, Priority.ALWAYS); HBox.setHgrow(passwordField, Priority.ALWAYS);
@ -235,6 +240,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
contentBox.setPadding(new Insets(10, 30, 10, 30)); contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60); contentBox.setPrefHeight(60);
javafx.application.Platform.runLater(passwordField::requestFocus);
return contentBox; return contentBox;
} }
} }

View file

@ -0,0 +1,175 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Control;
import javafx.scene.control.ToggleButton;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
public class FileKeystoreExportPane extends TitledDescriptionPane {
private final Keystore keystore;
private final KeystoreFileExport exporter;
private final boolean scannable;
private final boolean file;
public FileKeystoreExportPane(Keystore keystore, KeystoreFileExport exporter) {
super(exporter.getName(), "Keystore export", exporter.getKeystoreExportDescription(), exporter.getWalletModel());
this.keystore = keystore;
this.exporter = exporter;
this.scannable = exporter.isKeystoreExportScannable();
this.file = exporter.isKeystoreExportFile();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
}
@Override
protected Control createButton() {
if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
showButton.setSelected(false);
exportQR();
});
ToggleButton fileButton = new ToggleButton("Export File...");
fileButton.setAlignment(Pos.CENTER_RIGHT);
fileButton.setOnAction(event -> {
fileButton.setSelected(false);
exportFile();
});
SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton;
} else if(scannable) {
Button showButton = new Button("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
exportQR();
});
return showButton;
} else {
Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
exportButton.setOnAction(event -> {
exportFile();
});
return exportButton;
}
}
private void exportQR() {
exportKeystore(null, keystore);
}
private void exportFile() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(keystore);
String fileName = keystore.getLabel();
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
exportKeystore(file, keystore);
}
}
private void exportKeystore(File file, Keystore exportKeystore) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
exporter.exportKeystore(exportKeystore, baos);
if(exporter.requiresSignature()) {
String message = baos.toString(StandardCharsets.UTF_8);
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getWalletModel().isCard()) {
TextAreaDialog dialog = new TextAreaDialog(message, false);
dialog.initOwner(this.getScene().getWindow());
dialog.setTitle("Sign " + exporter.getName() + " Export");
dialog.getDialogPane().setHeaderText("The following text needs to be signed by the device.\nClick OK to continue.");
dialog.showAndWait();
Wallet wallet = new Wallet();
wallet.setScriptType(ScriptType.P2PKH);
wallet.getKeystores().add(keystore);
List<String> operationFingerprints = List.of(keystore.getKeyDerivation().getMasterFingerprint());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(operationFingerprints, wallet, message, keystore.getKeyDerivation());
deviceSignMessageDialog.initOwner(this.getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
exporter.addSignature(keystore, optSignature.get(), baos);
}
} else if(keystore.getSource() == KeystoreSource.SW_SEED) {
String signature = keystore.getExtendedPrivateKey().getKey().signMessage(message, ScriptType.P2PKH);
exporter.addSignature(keystore, signature, baos);
} else {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Cannot sign export",
"Signing the " + exporter.getName() + " export with " + keystore.getWalletModel().toDisplayString() + " is not supported." +
"Proceed without signing?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.NO) {
throw new RuntimeException("Export aborted due to lack of device message signing support.");
}
}
}
if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(baos.toByteArray());
EventManager.get().post(new KeystoreExportEvent(exportKeystore));
}
} else {
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof Bip129) {
UR ur = UR.fromBytes(baos.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, baos.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false);
} else {
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
}
}
}

View file

@ -16,7 +16,7 @@ public class FileKeystoreImportPane extends FileImportPane {
private final KeyDerivation requiredDerivation; private final KeyDerivation requiredDerivation;
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer, KeyDerivation requiredDerivation) { public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer, KeyDerivation requiredDerivation) {
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable()); super(importer, importer.getName(), "Key import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), importer.getWalletModel(), importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
this.wallet = wallet; this.wallet = wallet;
this.importer = importer; this.importer = importer;
this.requiredDerivation = requiredDerivation; this.requiredDerivation = requiredDerivation;
@ -29,22 +29,9 @@ public class FileKeystoreImportPane extends FileImportPane {
} }
if(requiredDerivation != null && !requiredDerivation.getDerivation().equals(keystore.getKeyDerivation().getDerivation())) { if(requiredDerivation != null && !requiredDerivation.getDerivation().equals(keystore.getKeyDerivation().getDerivation())) {
setError("Incorrect derivation", "This account requires a derivation of " + requiredDerivation.getDerivationPath() + ", but the imported keystore has a derivation of " + keystore.getKeyDerivation().getDerivationPath() + "."); setError("Incorrect derivation", "This account requires a derivation of " + requiredDerivation.getDerivationPath() + ", but the imported keystore has a derivation of " + KeyDerivation.writePath(keystore.getKeyDerivation().getDerivation()) + ".");
} else { } else {
EventManager.get().post(new KeystoreImportEvent(keystore)); EventManager.get().post(new KeystoreImportEvent(keystore));
} }
} }
private static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
if(wallet == null || requiredDerivation == null) {
return 0;
}
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
if(account < 0) {
account = 0;
}
return account;
}
} }

View file

@ -1,7 +1,11 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.CryptoOutput;
import com.sparrowwallet.hummingbird.registry.RegistryType; import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
@ -10,6 +14,10 @@ import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.WalletExportEvent; import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.Button; import javafx.scene.control.Button;
import javafx.scene.control.Control; import javafx.scene.control.Control;
@ -24,16 +32,20 @@ import java.nio.charset.StandardCharsets;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import static com.sparrowwallet.sparrow.wallet.SettingsController.getCryptoOutput;
public class FileWalletExportPane extends TitledDescriptionPane { public class FileWalletExportPane extends TitledDescriptionPane {
private final Wallet wallet; private final Wallet wallet;
private final WalletExport exporter; private final WalletExport exporter;
private final boolean scannable; private final boolean scannable;
private final boolean file;
public FileWalletExportPane(Wallet wallet, WalletExport exporter) { public FileWalletExportPane(Wallet wallet, WalletExport exporter) {
super(exporter.getName(), "Wallet file export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png"); super(exporter.getName(), "Wallet export", exporter.getWalletExportDescription(), exporter.getWalletModel());
this.wallet = wallet; this.wallet = wallet;
this.exporter = exporter; this.exporter = exporter;
this.scannable = exporter.isWalletExportScannable(); this.scannable = exporter.isWalletExportScannable();
this.file = exporter.isWalletExportFile();
buttonBox.getChildren().clear(); buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton()); buttonBox.getChildren().add(createButton());
@ -41,7 +53,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
@Override @Override
protected Control createButton() { protected Control createButton() {
if(scannable) { if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show..."); ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA); Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12); cameraGlyph.setFontSize(12);
@ -61,6 +73,15 @@ public class FileWalletExportPane extends TitledDescriptionPane {
SegmentedButton segmentedButton = new SegmentedButton(); SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton); segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton; return segmentedButton;
} else if(scannable) {
Button showButton = new Button("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
exportQR();
});
return showButton;
} else { } else {
Button exportButton = new Button("Export File..."); Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT); exportButton.setAlignment(Pos.CENTER_RIGHT);
@ -81,9 +102,11 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser(); FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File"); fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(wallet); String extension = exporter.getExportFileExtension(wallet);
String fileName = wallet.getFullName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", ""); String walletModel = exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
String postfix = walletModel.equals(extension) ? "" : "-" + walletModel;
String fileName = wallet.getFullName() + postfix;
if(exporter.exportsAllWallets()) { if(exporter.exportsAllWallets()) {
fileName = wallet.getMasterName(); fileName = wallet.getMasterName() + postfix;
} }
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension)); fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
@ -98,19 +121,16 @@ public class FileWalletExportPane extends TitledDescriptionPane {
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) { if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
Wallet copy = wallet.copy(); Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(buttonBox.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait(); Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) { if(password.isPresent()) {
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet); final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
String walletPassword = password.get().asString();
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get()); Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> { decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done")); EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue(); Wallet decryptedWallet = decryptWalletService.getValue();
exportWallet(file, decryptedWallet, walletPassword);
try {
exportWallet(file, decryptedWallet);
} finally {
decryptedWallet.clearPrivate();
}
}); });
decryptWalletService.setOnFailed(workerStateEvent -> { decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed")); EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
@ -120,28 +140,53 @@ public class FileWalletExportPane extends TitledDescriptionPane {
decryptWalletService.start(); decryptWalletService.start();
} }
} else { } else {
exportWallet(file, wallet); exportWallet(file, wallet, null);
} }
} }
private void exportWallet(File file, Wallet exportWallet) { private void exportWallet(File file, Wallet exportWallet, String password) {
try { try {
if(file != null) { if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) { FileWalletExportService fileWalletExportService = new FileWalletExportService(exporter, file, exportWallet, password);
exporter.exportWallet(exportWallet, outputStream); fileWalletExportService.setOnSucceeded(event -> {
EventManager.get().post(new WalletExportEvent(exportWallet)); EventManager.get().post(new WalletExportEvent(exportWallet));
} });
fileWalletExportService.setOnFailed(event -> {
Throwable e = event.getSource().getException();
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
});
fileWalletExportService.start();
} else { } else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(exportWallet, outputStream); exporter.exportWallet(exportWallet, outputStream, password);
QRDisplayDialog qrDisplayDialog; QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) { if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true); qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig) { } else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false); qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else if(exporter instanceof Bip129 || exporter instanceof WalletLabels) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
} else if(exporter instanceof Descriptor) {
boolean addBbqrOption = exportWallet.getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().showBbqr());
boolean selectBbqrOption = exportWallet.getKeystores().stream().allMatch(keystore -> keystore.getWalletModel().selectBbqr());
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.UNICODE, outputDescriptor.toString(true).getBytes(StandardCharsets.UTF_8)) : null;
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR(), bbqr, selectBbqrOption);
} else if(exporter.getClass().equals(ColdcardMultisig.class)) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, true);
} else { } else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8)); qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
} }
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
qrDisplayDialog.showAndWait(); qrDisplayDialog.showAndWait();
} }
} catch(Exception e) { } catch(Exception e) {
@ -150,6 +195,42 @@ public class FileWalletExportPane extends TitledDescriptionPane {
errorMessage = e.getCause().getMessage(); errorMessage = e.getCause().getMessage();
} }
setError("Export Error", errorMessage); setError("Export Error", errorMessage);
} finally {
if(file == null && password != null) {
exportWallet.clearPrivate();
}
}
}
public static class FileWalletExportService extends Service<Void> {
private final WalletExport exporter;
private final File file;
private final Wallet wallet;
private final String password;
public FileWalletExportService(WalletExport exporter, File file, Wallet wallet, String password) {
this.exporter = exporter;
this.file = file;
this.wallet = wallet;
this.password = password;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(wallet, outputStream, password);
} finally {
if(password != null) {
wallet.clearPrivate();
}
}
return null;
}
};
} }
} }
} }

View file

@ -12,13 +12,19 @@ public class FileWalletImportPane extends FileImportPane {
private final WalletImport importer; private final WalletImport importer;
public FileWalletImportPane(WalletImport importer) { public FileWalletImportPane(WalletImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), true); super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), importer.getWalletModel(), importer.isWalletImportScannable(), importer.isWalletImportFileFormatAvailable());
this.importer = importer; this.importer = importer;
} }
@Override @Override
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException { protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
Wallet wallet = importer.importWallet(inputStream, password); Wallet wallet;
if(getScannedWallets() != null && !getScannedWallets().isEmpty()) {
wallet = getScannedWallets().iterator().next();
} else {
wallet = importer.importWallet(inputStream, password);
}
if(wallet.getName() == null) { if(wallet.getName() == null) {
wallet.setName(fileName); wallet.setName(fileName);
} }

View file

@ -12,6 +12,7 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent; import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.ImportException; import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreFileImport; import com.sparrowwallet.sparrow.io.KeystoreFileImport;
import javafx.application.Platform;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
@ -38,14 +39,16 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
private final KeystoreFileImport importer; private final KeystoreFileImport importer;
private String fileName; private String fileName;
private byte[] fileBytes; private byte[] fileBytes;
private String password;
public FileWalletKeystoreImportPane(KeystoreFileImport importer) { public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable()); super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), importer.getWalletModel(), importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
this.importer = importer; this.importer = importer;
} }
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException { protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
this.fileName = fileName; this.fileName = fileName;
this.password = password;
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE); List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
if(wallets != null && !wallets.isEmpty()) { if(wallets != null && !wallets.isEmpty()) {
@ -83,7 +86,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
EventManager.get().post(new WalletImportEvent(wallet)); EventManager.get().post(new WalletImportEvent(wallet));
} else { } else {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes); ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, ""); Keystore keystore = importer.getKeystore(scriptType, bais, password);
Wallet wallet = new Wallet(); Wallet wallet = new Wallet();
wallet.setName(Files.getNameWithoutExtension(fileName)); wallet.setName(Files.getNameWithoutExtension(fileName));
@ -151,6 +154,8 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
contentBox.setPadding(new Insets(10, 30, 10, 30)); contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60); contentBox.setPrefHeight(60);
Platform.runLater(scriptTypeComboBox::requestFocus);
return contentBox; return contentBox;
} }
} }

View file

@ -0,0 +1,66 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.io.*;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Comparator;
import java.util.List;
public class KeystoreExportDialog extends Dialog<Keystore> {
public KeystoreExportDialog(Keystore keystore) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
stackPane.getChildren().add(anchorPane);
ScrollPane scrollPane = new ScrollPane();
scrollPane.setPrefHeight(200);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
List<KeystoreFileExport> exporters = List.of(new Bip129());
Accordion exportAccordion = new Accordion();
for(KeystoreFileExport exporter : exporters) {
if(!exporter.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileKeystoreExportPane exportPane = new FileKeystoreExportPane(keystore, exporter);
exportAccordion.getPanes().add(exportPane);
}
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(exportAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(280);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? keystore : null);
}
@Subscribe
public void keystoreExported(KeystoreExportEvent event) {
setResult(event.getKeystore());
}
}

View file

@ -1,20 +1,23 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.control.ButtonType; import javafx.beans.property.ObjectProperty;
import javafx.scene.control.Dialog; import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.DialogPane; import javafx.geometry.Pos;
import javafx.scene.control.Label; import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField; import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
public class KeystorePassphraseDialog extends Dialog<String> { public class KeystorePassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase; private final CustomPasswordField passphrase;
private final ObjectProperty<byte[]> masterFingerprint = new SimpleObjectProperty<>();
public KeystorePassphraseDialog(Keystore keystore) { public KeystorePassphraseDialog(Keystore keystore) {
this(null, keystore); this(null, keystore);
@ -45,10 +48,38 @@ public class KeystorePassphraseDialog extends Dialog<String> {
content.setPrefHeight(50); content.setPrefHeight(50);
content.getChildren().add(passphrase); content.getChildren().add(passphrase);
passphrase.textProperty().addListener((observable, oldValue, passphrase) -> {
masterFingerprint.set(getMasterFingerprint(keystore, passphrase));
});
HBox fingerprintBox = new HBox(10);
fingerprintBox.setAlignment(Pos.CENTER_LEFT);
Label fingerprintLabel = new Label("Master fingerprint:");
TextField fingerprintHex = new TextField();
fingerprintHex.setDisable(true);
fingerprintHex.setMaxWidth(80);
fingerprintHex.getStyleClass().addAll("fixed-width");
fingerprintHex.setStyle("-fx-opacity: 0.6");
masterFingerprint.addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
fingerprintHex.setText(Utils.bytesToHex(newValue));
}
});
LifeHashIcon lifeHashIcon = new LifeHashIcon();
lifeHashIcon.dataProperty().bind(masterFingerprint);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("All passphrases create valid wallets." +
"\nThe master fingerprint identifies the keystore and changes as the passphrase changes." +
"\n" + (confirm ? "Take a moment to identify it before proceeding." : "Make sure you recognise it before proceeding."));
fingerprintBox.getChildren().addAll(fingerprintLabel, fingerprintHex, lifeHashIcon, helpLabel);
content.getChildren().add(fingerprintBox);
masterFingerprint.set(getMasterFingerprint(keystore, ""));
Glyph warnGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE); Glyph warnGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
warnGlyph.getStyleClass().add("warn-icon"); warnGlyph.getStyleClass().add("warn-icon");
warnGlyph.setFontSize(12); warnGlyph.setFontSize(12);
Label warnLabel = new Label("A BIP39 passphrase is not a wallet password!", warnGlyph); Label warnLabel = new Label((confirm ? "Note" : "Check") + " the master fingerprint before proceeding!", warnGlyph);
warnLabel.setGraphicTextGap(5); warnLabel.setGraphicTextGap(5);
content.getChildren().add(warnLabel); content.getChildren().add(warnLabel);
@ -57,4 +88,14 @@ public class KeystorePassphraseDialog extends Dialog<String> {
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? passphrase.getText() : null); setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? passphrase.getText() : null);
} }
private byte[] getMasterFingerprint(Keystore keystore, String passphrase) {
try {
Keystore copyKeystore = keystore.copy();
copyKeystore.getSeed().setPassphrase(passphrase);
return copyKeystore.getExtendedMasterPrivateKey().getKey().getFingerprint();
} catch(MnemonicException e) {
throw new RuntimeException(e);
}
}
} }

View file

@ -1,21 +1,31 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.Persistable;
import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.event.Event; import javafx.event.Event;
import javafx.geometry.Point2D;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTreeTableCell; import javafx.scene.control.cell.TextFieldTreeTableCell;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent; import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DataFormat; import javafx.scene.input.DataFormat;
import javafx.util.Duration;
import javafx.util.converter.DefaultStringConverter; import javafx.util.converter.DefaultStringConverter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.lang.reflect.Field; import java.lang.reflect.Field;
class LabelCell extends TextFieldTreeTableCell<Entry, String> { class LabelCell extends TextFieldTreeTableCell<Entry, String> implements ConfirmationsListener {
private static final Logger log = LoggerFactory.getLogger(LabelCell.class); private static final Logger log = LoggerFactory.getLogger(LabelCell.class);
private IntegerProperty confirmationsProperty;
public LabelCell() { public LabelCell() {
super(new DefaultStringConverter()); super(new DefaultStringConverter());
getStyleClass().add("label-cell"); getStyleClass().add("label-cell");
@ -28,12 +38,23 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
if(empty) { if(empty) {
setText(null); setText(null);
setGraphic(null); setGraphic(null);
setTooltip(null);
} else { } else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue(); Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry); EntryCell.applyRowStyles(this, entry);
setText(label); setText(label);
setContextMenu(new LabelContextMenu(entry, label)); setContextMenu(new LabelContextMenu(entry, label));
double width = label == null || label.length() < 20 ? 0.0 : TextUtils.computeTextWidth(getFont(), label, 0.0D);
if(width > getTableColumn().getWidth()) {
Tooltip tooltip = new Tooltip(label);
tooltip.setMaxWidth(getTreeTableView().getWidth());
tooltip.setWrapText(true);
setTooltip(tooltip);
} else {
setTooltip(null);
}
} }
} }
@ -41,6 +62,20 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
public void commitEdit(String label) { public void commitEdit(String label) {
if(label != null) { if(label != null) {
label = label.trim(); label = label.trim();
if(label.length() > Persistable.MAX_LABEL_LENGTH) {
label = label.substring(0, Persistable.MAX_LABEL_LENGTH);
Platform.runLater(() -> {
Point2D p = this.localToScene(0.0, 0.0);
final Tooltip truncateTooltip = new Tooltip();
truncateTooltip.setText("Labels are truncated at " + Persistable.MAX_LABEL_LENGTH + " characters");
truncateTooltip.setAutoHide(true);
truncateTooltip.show(this, p.getX() + this.getScene().getX() + this.getScene().getWindow().getX() + this.getHeight(),
p.getY() + this.getScene().getY() + this.getScene().getWindow().getY() + this.getHeight());
PauseTransition pt = new PauseTransition(Duration.millis(2000));
pt.setOnFinished(_ -> truncateTooltip.hide());
pt.play();
});
}
} }
// This block is necessary to support commit on losing focus, because // This block is necessary to support commit on losing focus, because
@ -60,6 +95,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
} }
super.commitEdit(label); super.commitEdit(label);
Platform.runLater(() -> getTreeTableView().requestFocus());
} }
@Override @Override
@ -81,7 +117,22 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
} }
} }
private static class LabelContextMenu extends ContextMenu { @Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Entry entry, String label) { public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label"); MenuItem copyLabel = new MenuItem("Copy Label");
copyLabel.setOnAction(AE -> { copyLabel.setOnAction(AE -> {
@ -101,6 +152,13 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
} }
}); });
getItems().add(pasteLabel); getItems().add(pasteLabel);
MenuItem editLabel = new MenuItem("Edit Label...");
editLabel.setOnAction(AE -> {
hide();
startEdit();
});
getItems().add(editLabel);
} }
} }
} }

View file

@ -0,0 +1,70 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.sparrow.io.ImageUtils;
import com.sparrowwallet.toucan.LifeHash;
import com.sparrowwallet.toucan.LifeHashVersion;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Group;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Rectangle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.image.BufferedImage;
import java.util.Arrays;
public class LifeHashIcon extends Group {
private static final Logger log = LoggerFactory.getLogger(LifeHashIcon.class);
private static final int SIZE = 24;
private final ObjectProperty<byte[]> dataProperty = new SimpleObjectProperty<>(null);
public LifeHashIcon() {
super();
dataProperty.addListener((observable, oldValue, data) -> {
if(data == null) {
getChildren().clear();
} else if(oldValue == null || !Arrays.equals(oldValue, data)) {
LifeHash.Image lifeHashImage = LifeHash.makeFromData(data, LifeHashVersion.VERSION2, 1, false);
BufferedImage bufferedImage = LifeHash.getBufferedImage(lifeHashImage);
BufferedImage resizedImage = ImageUtils.resizeToImage(bufferedImage, SIZE, SIZE);
Image image = SwingFXUtils.toFXImage(resizedImage, null);
setImage(image);
}
});
}
private void setImage(Image image) {
getChildren().clear();
Rectangle rectangle = new Rectangle(SIZE, SIZE);
rectangle.setArcWidth(6);
rectangle.setArcHeight(6);
rectangle.setFill(new ImagePattern(image));
rectangle.setStroke(Color.rgb(65, 72, 77));
rectangle.setStrokeWidth(1.0);
getChildren().add(rectangle);
}
public byte[] getData() {
return dataProperty.get();
}
public ObjectProperty<byte[]> dataProperty() {
return dataProperty;
}
public void setData(byte[] data) {
this.dataProperty.set(data);
}
public void setHex(String hex) {
setData(hex == null ? null : Utils.hexToBytes(hex));
}
}

View file

@ -1,21 +1,33 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.MempoolRateSize; import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.NamedArg; import javafx.beans.NamedArg;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D; import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Cursor; import javafx.scene.Cursor;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.*; import javafx.scene.chart.*;
import javafx.scene.control.Label; import javafx.scene.control.*;
import javafx.scene.control.Tooltip; import javafx.scene.input.MouseButton;
import javafx.scene.layout.HBox; import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox; import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration; import javafx.util.Duration;
import javafx.util.StringConverter; import javafx.util.StringConverter;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat; import java.text.DateFormat;
@ -27,16 +39,100 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> { public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm"); private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
public static final int MAX_PERIOD_HOURS = 2; public static final int DEFAULT_MAX_PERIOD_HOURS = 2;
private static final double Y_VALUE_BREAK_MVB = 3.0; private static final double Y_VALUE_BREAK_MVB = 3.0;
private static final List<Integer> FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800);
private int maxPeriodHours = DEFAULT_MAX_PERIOD_HOURS;
private Tooltip tooltip; private Tooltip tooltip;
private MempoolSizeFeeRatesChart expandedChart;
private final EventHandler<MouseEvent> expandedChartHandler = new EventHandler<>() {
@Override
public void handle(MouseEvent event) {
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle("Mempool by vBytes");
stage.initOwner(MempoolSizeFeeRatesChart.this.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
StackPane scenePane = new StackPane();
if(OsType.getCurrent() == OsType.WINDOWS) {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
if(Config.get().getTheme() == Theme.DARK) {
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
VBox vBox = new VBox(20);
vBox.setPadding(new Insets(20, 20, 20, 20));
expandedChart = new MempoolSizeFeeRatesChart();
expandedChart.initialize();
expandedChart.getStyleClass().add("vsizeChart");
expandedChart.update(AppServices.getMempoolHistogram());
expandedChart.setLegendVisible(false);
expandedChart.setAnimated(false);
expandedChart.setPrefWidth(700);
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER);
ToggleGroup periodGroup = new ToggleGroup();
ToggleButton period2 = new ToggleButton("2H");
ToggleButton period24 = new ToggleButton("24H");
SegmentedButton periodButtons = new SegmentedButton(period2, period24);
periodButtons.setToggleGroup(periodGroup);
periodGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
expandedChart.maxPeriodHours = (newValue == period2 ? 2 : 24);
expandedChart.update(AppServices.getMempoolHistogram());
});
Optional<Date> optEarliest = AppServices.getMempoolHistogram().keySet().stream().findFirst();
period24.setDisable(optEarliest.isEmpty() || optEarliest.get().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().isAfter(LocalDateTime.now().minusHours(2)));
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
Button button = new Button("Close");
button.setOnAction(e -> {
stage.close();
});
buttonBox.getChildren().addAll(periodButtons, region, button);
vBox.getChildren().addAll(expandedChart, buttonBox);
scenePane.getChildren().add(vBox);
Scene scene = new Scene(scenePane);
AppServices.onEscapePressed(scene, stage::close);
AppServices.setStageIcon(stage);
stage.setScene(scene);
stage.setOnShowing(e -> {
AppServices.moveToActiveWindowScreen(stage, 800, 460);
});
stage.setOnHidden(e -> {
expandedChart = null;
});
stage.show();
}
}
};
public MempoolSizeFeeRatesChart() {
super(new CategoryAxis(), new NumberAxis());
}
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) { public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis); super(xAxis, yAxis);
setOnMouseClicked(expandedChartHandler);
} }
public void initialize() { public void initialize() {
getStyleClass().add("vsizeChart");
setCreateSymbols(false); setCreateSymbols(false);
setCursor(Cursor.CROSSHAIR); setCursor(Cursor.CROSSHAIR);
setVerticalGridLinesVisible(false); setVerticalGridLinesVisible(false);
@ -78,17 +174,18 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
} }
}); });
long previousFeeRate = 0; for(int i = 0; i < FEE_RATES_INTERVALS.size(); i++) {
for(Long feeRate : AppServices.FEE_RATES_RANGE) { int feeRate = FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : FEE_RATES_INTERVALS.get(i+1));
XYChart.Series<String, Number> series = new XYChart.Series<>(); XYChart.Series<String, Number> series = new XYChart.Series<>();
series.setName(feeRate + "+ sats/vB"); series.setName(feeRate + "-" + (nextFeeRate == Integer.MAX_VALUE ? 900 : nextFeeRate));
long seriesTotalVSize = 0; long seriesTotalVSize = 0;
for(Date date : periodRateSizes.keySet()) { for(Date date : periodRateSizes.keySet()) {
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date); Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
long totalVSize = 0; long totalVSize = 0;
for(MempoolRateSize rateSize : rateSizes) { for(MempoolRateSize rateSize : rateSizes) {
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) { if(rateSize.getFee() >= feeRate && rateSize.getFee() < nextFeeRate) {
totalVSize += rateSize.getVSize(); totalVSize += rateSize.getVSize();
} }
} }
@ -100,8 +197,19 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
if(seriesTotalVSize > 0) { if(seriesTotalVSize > 0) {
getData().add(series); getData().add(series);
} }
}
previousFeeRate = feeRate; for(int i = 0; i < getData().size(); i++) {
Series<String, Number> series = getData().get(i);
Set<Node> nodes = lookupAll(".series" + i);
for(Node node : nodes) {
if(node.getStyleClass().contains("chart-series-area-line")) {
node.setStyle("-fx-stroke: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.2;");
} else {
node.setStyle("-fx-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.5;");
}
node.getStyleClass().remove("default-color" + i);
}
} }
final double maxMvB = getMaxMvB(getData()); final double maxMvB = getMaxMvB(getData());
@ -131,6 +239,10 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
numberAxis.setTickLabelsVisible(false); numberAxis.setTickLabelsVisible(false);
numberAxis.setOpacity(0); numberAxis.setOpacity(0);
} }
if(expandedChart != null) {
expandedChart.update(mempoolRateSizes);
}
} }
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) { private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
@ -138,7 +250,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
return mempoolRateSizes; return mempoolRateSizes;
} }
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS); LocalDateTime period = LocalDateTime.now().minusHours(maxPeriodHours);
return mempoolRateSizes.entrySet().stream().filter(entry -> { return mempoolRateSizes.entrySet().stream().filter(entry -> {
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return dateTime.isAfter(period); return dateTime.isAfter(period);
@ -200,11 +312,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
double mvb = kvb / 1000; double mvb = kvb / 1000;
if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) { if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) {
String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB"); String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB");
Label label = new Label(series.getName() + ": " + amount); Label label = new Label(series.getName() + " sats/vB: " + amount);
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE); Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
if(i < 8) { circle.setStyle("-fx-text-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.7;");
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
}
label.setGraphic(circle); label.setGraphic(circle);
getChildren().add(label); getChildren().add(label);
} }

View file

@ -2,29 +2,31 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe; import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.Bip322;
import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.event.StorageEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView; import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.SegmentedButton; import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult; import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport; import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration; import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
@ -34,24 +36,31 @@ import tornadofx.control.Field;
import tornadofx.control.Fieldset; import tornadofx.control.Fieldset;
import tornadofx.control.Form; import tornadofx.control.Form;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.SignatureException; import java.security.SignatureException;
import java.util.Arrays; import java.util.*;
import java.util.List; import java.util.regex.Matcher;
import java.util.Locale; import java.util.regex.Pattern;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> { public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class); private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
private static final Pattern signedMessagePattern = Pattern.compile("-----BEGIN BITCOIN SIGNED MESSAGE-----\\r?\\n(.*)\\r?\\n-----BEGIN BITCOIN SIGNATURE-----\\r?\\n(.*)\\r?\\n(.*)\\r?\\n-----END BITCOIN SIGNATURE-----\r?\n?");
private final TextField address; private final TextField address;
private final TextArea message; private final TextArea message;
private final TextArea signature; private final TextArea signature;
private final ToggleGroup formatGroup; private final ToggleGroup formatGroup;
private final ToggleButton formatTrezor; private final ToggleButton formatTrezor;
private final ToggleButton formatElectrum; private final ToggleButton formatElectrum;
private final ToggleButton formatBip322;
private final Wallet wallet; private final Wallet wallet;
private WalletNode walletNode; private WalletNode walletNode;
private boolean electrumSignatureFormat; private boolean canSign;
private boolean closed; private boolean closed;
/** /**
@ -92,28 +101,24 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) { public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
if(walletNode != null) { if(walletNode != null) {
checkWalletSigning(walletNode.getWallet()); checkWalletSigning(walletNode.getWallet());
this.canSign = canSign(walletNode.getWallet());
} }
if(wallet != null) { if(wallet != null) {
checkWalletSigning(wallet); checkWalletSigning(wallet);
this.canSign = canSign(wallet);
} }
this.wallet = wallet; this.wallet = wallet;
this.walletNode = walletNode; this.walletNode = walletNode;
final DialogPane dialogPane = getDialogPane(); final DialogPane dialogPane = new MessageSignDialogPane();
setDialogPane(dialogPane);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow()); AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title); dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title);
dialogPane.setGraphic(new WalletModelImage(WalletModel.SEED));
Image image = new Image("image/seed.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
VBox vBox = new VBox(); VBox vBox = new VBox();
vBox.setSpacing(20); vBox.setSpacing(20);
@ -128,7 +133,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address = new TextField(); address = new TextField();
address.getStyleClass().add("id"); address.getStyleClass().add("id");
address.setEditable(walletNode == null); address.setEditable(walletNode == null);
address.setTooltip(new Tooltip("Only Legacy (P2PKH), Nested Segwit (P2SH-P2WPKH) and Native Segwit (P2WPKH) singlesig addresses can sign")); address.setTooltip(new Tooltip("Only singlesig addresses can sign"));
addressField.getInputs().add(address); addressField.getInputs().add(address);
if(walletNode != null) { if(walletNode != null) {
@ -139,17 +144,18 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
messageField.setText("Message:"); messageField.setText("Message:");
message = new TextArea(); message = new TextArea();
message.setWrapText(true); message.setWrapText(true);
message.setPrefRowCount(10); message.setPrefRowCount(8);
message.setStyle("-fx-pref-height: 180px"); message.setStyle("-fx-pref-height: 160px");
messageField.getInputs().add(message); messageField.getInputs().add(message);
Field signatureField = new Field(); Field signatureField = new Field();
signatureField.setText("Signature:"); signatureField.setText("Signature:");
signature = new TextArea(); signature = new TextArea();
signature.getStyleClass().add("id"); signature.getStyleClass().add("id");
signature.setPrefRowCount(2); signature.setPrefRowCount(4);
signature.setStyle("-fx-pref-height: 60px"); signature.setStyle("-fx-pref-height: 80px");
signature.setWrapText(true); signature.setWrapText(true);
signature.setOnMouseClicked(event -> signature.selectAll());
signatureField.getInputs().add(signature); signatureField.getInputs().add(signature);
Field formatField = new Field(); Field formatField = new Field();
@ -157,16 +163,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
formatGroup = new ToggleGroup(); formatGroup = new ToggleGroup();
formatElectrum = new ToggleButton("Standard (Electrum)"); formatElectrum = new ToggleButton("Standard (Electrum)");
formatTrezor = new ToggleButton("BIP137 (Trezor)"); formatTrezor = new ToggleButton("BIP137 (Trezor)");
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor); formatBip322 = new ToggleButton("BIP322 (Simple)");
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor, formatBip322);
formatButtons.setToggleGroup(formatGroup); formatButtons.setToggleGroup(formatGroup);
formatField.getInputs().add(formatButtons); formatField.getInputs().add(formatButtons);
formatGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
electrumSignatureFormat = (newValue == formatElectrum);
});
formatButtons.setDisable(wallet != null && walletNode != null && wallet.getScriptType() == ScriptType.P2PKH);
fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField); fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField);
form.getChildren().add(fieldset); form.getChildren().add(fieldset);
dialogPane.setContent(form); dialogPane.setContent(form);
@ -179,6 +180,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
formatButtons.setDisable(true); formatButtons.setDisable(true);
} }
ButtonType showQrButtonType = new javafx.scene.control.ButtonType("Sign by QR", ButtonBar.ButtonData.LEFT);
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS); ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD); ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE); ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE);
@ -197,10 +199,14 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}); });
} }
} else { } else {
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType); dialogPane.getButtonTypes().addAll(showQrButtonType, signButtonType, verifyButtonType, doneButtonType);
Node showQrButton = dialogPane.lookupButton(showQrButtonType);
Button signButton = (Button) dialogPane.lookupButton(signButtonType); Button signButton = (Button) dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null); signButton.setDisable(!canSign);
signButton.setGraphic(getGlyph(getSignGlyph()));
signButton.setGraphicTextGap(5);
signButton.setOnAction(event -> { signButton.setOnAction(event -> {
signMessage(); signMessage();
}); });
@ -212,7 +218,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}); });
boolean validAddress = isValidAddress(); boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null)); showQrButton.setDisable(!validAddress || (wallet == null));
signButton.setDisable(!validAddress || !canSign);
verifyButton.setDisable(!validAddress); verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport(); ValidationSupport validationSupport = new ValidationSupport();
@ -223,13 +230,20 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address.textProperty().addListener((observable, oldValue, newValue) -> { address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress(); boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null)); showQrButton.setDisable(!valid || (wallet == null));
signButton.setDisable(!valid || !canSign);
verifyButton.setDisable(!valid); verifyButton.setDisable(!valid);
if(valid && wallet != null) { if(valid) {
try { try {
Address address = getAddress(); Address address = getAddress();
setWalletNodeFromAddress(wallet, address); setFormatFromScriptType(address.getScriptType());
if(wallet != null) {
setWalletNodeFromAddress(wallet, address);
if(walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
}
}
} catch(InvalidAddressException e) { } catch(InvalidAddressException e) {
//can't happen //can't happen
} }
@ -261,7 +275,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
message.requestFocus(); message.requestFocus();
} }
formatGroup.selectToggle(formatElectrum); if(wallet != null && walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
} else {
formatGroup.selectToggle(formatElectrum);
}
}); });
} }
@ -269,9 +287,16 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(wallet.getKeystores().size() != 1) { if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required"); throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
} }
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) { }
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore");
} private boolean canSign(Wallet wallet) {
return wallet.getKeystores().getFirst().hasPrivateKey()
|| wallet.getKeystores().getFirst().getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().getFirst().getWalletModel().isCard();
}
private boolean canSignBip322(Wallet wallet) {
return wallet.getKeystores().getFirst().hasPrivateKey();
} }
private Address getAddress()throws InvalidAddressException { private Address getAddress()throws InvalidAddressException {
@ -282,20 +307,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return signature.getText(); return signature.getText();
} }
/**
* Use the Electrum signing format, which uses the non-segwit compressed signing parameters for both segwit types (p2sh-p2wpkh and p2wpkh)
*
* @param electrumSignatureFormat
*/
public void setElectrumSignatureFormat(boolean electrumSignatureFormat) {
formatGroup.selectToggle(electrumSignatureFormat ? formatElectrum : formatTrezor);
this.electrumSignatureFormat = electrumSignatureFormat;
}
private boolean isValidAddress() { private boolean isValidAddress() {
try { try {
Address address = getAddress(); Address address = getAddress();
return address.getScriptType() != ScriptType.P2TR && address.getScriptType().isAllowed(PolicyType.SINGLE); return address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH;
} catch (InvalidAddressException e) { } catch (InvalidAddressException e) {
return false; return false;
} }
@ -305,31 +320,66 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address); walletNode = wallet.getWalletAddresses().get(address);
} }
private ScriptType getSigningScriptType(WalletNode walletNode) {
ScriptType scriptType = walletNode.getWallet().getScriptType();
return canSign(walletNode.getWallet()) && !canSignBip322(walletNode.getWallet()) ? ScriptType.P2PKH : scriptType;
}
private void setFormatFromScriptType(ScriptType scriptType) {
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
formatBip322.setDisable(scriptType != ScriptType.P2WPKH && scriptType != ScriptType.P2TR);
if(scriptType == ScriptType.P2TR) {
formatGroup.selectToggle(formatBip322);
} else if(formatGroup.getSelectedToggle() == null || scriptType == ScriptType.P2PKH || (scriptType != ScriptType.P2WPKH && formatBip322.isSelected())) {
formatGroup.selectToggle(formatElectrum);
}
}
private boolean isBip322() {
return formatBip322.isSelected();
}
private boolean isElectrumSignatureFormat() {
return formatElectrum.isSelected();
}
private void signMessage() { private void signMessage() {
if(walletNode == null) { if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet."); AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return; return;
} }
if(!canSign) {
AppServices.showErrorDialog("Wallet can't sign", "This wallet cannot sign a message.");
return;
}
//Note we can expect a single keystore due to the check in the constructor //Note we can expect a single keystore due to the check in the constructor
Wallet signingWallet = walletNode.getWallet(); Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) { if(signingWallet.getKeystores().getFirst().hasPrivateKey()) {
if(signingWallet.isEncrypted()) { if(signingWallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent()); EventManager.get().post(new RequestOpenWalletsEvent());
} else { } else {
signUnencryptedKeystore(signingWallet); signUnencryptedKeystore(signingWallet);
} }
} else if(signingWallet.containsSource(KeystoreSource.HW_USB)) { } else if(signingWallet.containsSource(KeystoreSource.HW_USB) || wallet.getKeystores().get(0).getWalletModel().isCard()) {
signUsbKeystore(signingWallet); signDeviceKeystore(signingWallet);
} }
} }
private void signUnencryptedKeystore(Wallet decryptedWallet) { private void signUnencryptedKeystore(Wallet decryptedWallet) {
try { try {
Keystore keystore = decryptedWallet.getKeystores().get(0); Keystore keystore = decryptedWallet.getKeystores().getFirst();
ECKey privKey = keystore.getKey(walletNode); ECKey privKey = keystore.getKey(walletNode);
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType(); String signatureText;
String signatureText = privKey.signMessage(message.getText().trim(), scriptType); if(isBip322()) {
ScriptType scriptType = decryptedWallet.getScriptType();
signatureText = Bip322.signMessageBip322(scriptType, message.getText().trim(), privKey);
} else {
ScriptType scriptType = isElectrumSignatureFormat() ? ScriptType.P2PKH : decryptedWallet.getScriptType();
signatureText = privKey.signMessage(message.getText().trim(), scriptType);
}
signature.clear(); signature.clear();
signature.appendText(signatureText); signature.appendText(signatureText);
privKey.clear(); privKey.clear();
@ -339,10 +389,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} }
} }
private void signUsbKeystore(Wallet usbWallet) { private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint()); List<String> fingerprints = List.of(deviceWallet.getKeystores().getFirst().getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation()); KeyDerivation fullDerivation = deviceWallet.getKeystores().getFirst().getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation); DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait(); Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) { if(optSignature.isPresent()) {
signature.clear(); signature.clear();
@ -356,16 +407,33 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6 //http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
boolean verified = false; boolean verified = false;
try { try {
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), false); ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), true);
verified = verifyMessage(signedMessageKey); verified = verifyMessage(signedMessageKey);
if(verified) {
formatGroup.selectToggle(formatElectrum);
}
} catch(SignatureException e) { } catch(SignatureException e) {
//ignore //ignore
} }
if(!verified) { if(!verified) {
try { try {
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true); ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), false);
verified = verifyMessage(electrumSignedMessageKey); verified = verifyMessage(electrumSignedMessageKey);
if(verified) {
formatGroup.selectToggle(formatTrezor);
}
} catch(SignatureException e) {
//ignore
}
}
if(!verified && Bip322.isSupported(getAddress().getScriptType()) && !signature.getText().trim().isEmpty()) {
try {
verified = Bip322.verifyMessageBip322(getAddress().getScriptType(), getAddress(), message.getText().trim(), signature.getText().trim());
if(verified) {
formatGroup.selectToggle(formatBip322);
}
} catch(SignatureException e) { } catch(SignatureException e) {
//ignore //ignore
} }
@ -398,6 +466,136 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return providedAddress.equals(signedMessageAddress); return providedAddress.equals(signedMessageAddress);
} }
private void showQr() {
if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
//Note we can expect a single keystore due to the check in the constructor
KeyDerivation firstDerivation = walletNode.getWallet().getKeystores().get(0).getKeyDerivation();
String derivationPath = KeyDerivation.writePath(firstDerivation.extend(walletNode.getDerivation()).getDerivation(), false);
String qrText = "signmessage " + derivationPath + " ascii:" + message.getText().trim();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(qrText, true);
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
scanQr();
}
}
private void scanQr() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.payload != null) {
signature.clear();
signature.appendText(result.payload);
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
showErrorDialog("Error scanning QR", result.exception.getMessage());
} else {
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a signature.");
}
}
}
private void exportFile() {
if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
StringJoiner joiner = new StringJoiner("\n");
joiner.add(message.getText().trim().replaceAll("\r*\n*", ""));
//Note we can expect a single keystore due to the check in the constructor
KeyDerivation firstDerivation = walletNode.getWallet().getKeystores().get(0).getKeyDerivation();
joiner.add(KeyDerivation.writePath(firstDerivation.extend(walletNode.getDerivation()).getDerivation(), true));
joiner.add(walletNode.getWallet().getScriptType().toString());
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Text File");
fileChooser.setInitialFileName("signmessage.txt");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!file.getName().toLowerCase(Locale.ROOT).endsWith(".txt")) {
file = new File(file.getAbsolutePath() + ".txt");
}
try(BufferedWriter writer = new BufferedWriter(new FileWriter(file, StandardCharsets.UTF_8))) {
writer.write(joiner.toString());
} catch(IOException e) {
log.error("Error saving signing message", e);
AppServices.showErrorDialog("Error saving signing message", "Cannot write to " + file.getAbsolutePath());
}
}
}
private void importFile() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open Signed Text File");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", OsType.getCurrent().equals(OsType.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("Text Files", "*.txt")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
try {
String content = Files.readString(file.toPath(), StandardCharsets.UTF_8);
Matcher matcher = signedMessagePattern.matcher(content);
if(matcher.matches()) {
String signedMessage = matcher.group(1);
String signedAddress = matcher.group(2);
String signedSignature = matcher.group(3);
if(!message.getText().isEmpty() && !signedMessage.trim().equals(message.getText().trim().replaceAll("\r*\n*", ""))) {
AppServices.showErrorDialog("Incorrect Message", "The file contained a different message of:\n\n" + signedMessage);
return;
} else if(!signedAddress.trim().equals(address.getText().trim())) {
AppServices.showErrorDialog("Incorrect Address", "The file contained a different address of:\n\n" + signedAddress);
return;
}
message.setText(signedMessage);
signature.setText(signedSignature);
} else {
signature.setText(content);
}
} catch(IOException e) {
log.error("Error loading signed message", e);
AppServices.showErrorDialog("Error loading signed message", e.getMessage());
}
}
}
protected Glyph getSignGlyph() {
if(wallet != null) {
if(wallet.containsSource(KeystoreSource.HW_USB)) {
return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
} else if(wallet.getKeystores().get(0).getWalletModel().isCard()) {
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
}
}
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
}
private static Glyph getGlyph(Glyph glyph) {
glyph.setFontSize(11);
return glyph;
}
@Subscribe @Subscribe
public void openWallets(OpenWalletsEvent event) { public void openWallets(OpenWalletsEvent event) {
Storage storage = event.getStorage(wallet); Storage storage = event.getStorage(wallet);
@ -407,6 +605,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} }
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(getDialogPane().getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait(); Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) { if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get()); Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get());
@ -418,10 +617,43 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}); });
decryptWalletService.setOnFailed(workerStateEvent -> { decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed")); EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage()); AppServices.showErrorDialog("Incorrect Password", "The password was incorrect.");
}); });
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet...")); EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start(); decryptWalletService.start();
} }
} }
private class MessageSignDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
SplitMenuButton signByButton = new SplitMenuButton();
signByButton.setText("Sign by QR");
signByButton.setDisable(wallet == null);
signByButton.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE)));
signByButton.setGraphicTextGap(5);
signByButton.setOnAction(event -> {
showQr();
});
MenuItem exportFile = new MenuItem("Sign by File...");
exportFile.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.FILE_EXPORT)));
exportFile.setOnAction(event -> {
exportFile();
});
MenuItem importFile = new MenuItem("Load Signed File...");
importFile.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.FILE_IMPORT)));
importFile.setOnAction(event -> {
importFile();
});
signByButton.getItems().addAll(exportFile, importFile);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(signByButton, buttonData);
return signByButton;
}
return super.createButton(buttonType);
}
}
} }

View file

@ -1,27 +1,11 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.mix.listener.MixStep;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry; import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolException;
import javafx.animation.Timeline;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import java.util.Locale;
public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> { public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
private static final int ERROR_DISPLAY_MILLIS = 5 * 60 * 1000;
public MixStatusCell() { public MixStatusCell() {
super(); super();
setAlignment(Pos.CENTER_RIGHT); setAlignment(Pos.CENTER_RIGHT);
@ -41,174 +25,9 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
setGraphic(null); setGraphic(null);
} else { } else {
setText(Integer.toString(mixStatus.getMixesDone())); setText(Integer.toString(mixStatus.getMixesDone()));
if(mixStatus.getNextMixUtxo() == null) { setContextMenu(null);
setContextMenu(new MixStatusContextMenu(mixStatus.getUtxoEntry(), mixStatus.getMixProgress() != null && mixStatus.getMixProgress().getMixStep() != MixStep.FAIL));
} else {
setContextMenu(null);
}
if(mixStatus.getNextMixUtxo() != null) {
setMixSuccess(mixStatus.getNextMixUtxo());
} else if(mixStatus.getMixFailReason() != null) {
setMixFail(mixStatus.getMixFailReason(), mixStatus.getMixError(), mixStatus.getMixErrorTimestamp());
} else if(mixStatus.getMixProgress() != null) {
setMixProgress(mixStatus.getUtxoEntry(), mixStatus.getMixProgress());
} else {
setGraphic(null);
setTooltip(null);
}
}
}
private void setMixSuccess(Utxo nextMixUtxo) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(-1);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
tt.setText("Waiting for broadcast of " + nextMixUtxo.getHash().substring(0, 8) + "..." + ":" + nextMixUtxo.getIndex() );
setTooltip(tt);
}
private void setMixFail(MixFailReason mixFailReason, String mixError, Long mixErrorTimestamp) {
if(mixFailReason != MixFailReason.CANCEL) {
long elapsed = mixErrorTimestamp == null ? 0L : System.currentTimeMillis() - mixErrorTimestamp;
if(elapsed >= ERROR_DISPLAY_MILLIS) {
//Old error, don't set again.
return;
}
Glyph failGlyph = getFailGlyph();
setGraphic(failGlyph);
Tooltip tt = new Tooltip();
tt.setText(mixFailReason.getMessage() + (mixError == null ? "" : ": " + mixError) +
"\nMix failures are generally caused by peers disconnecting during a mix." +
"\nMake sure your internet connection is stable and the computer is configured to prevent sleeping." +
"\nTo prevent sleeping, use the " + getPlatformSleepConfig() + " or enable the function in the Tools menu.");
setTooltip(tt);
Duration fadeDuration = Duration.millis(ERROR_DISPLAY_MILLIS - elapsed);
double fadeFromValue = 1.0 - ((double)elapsed / ERROR_DISPLAY_MILLIS);
Timeline timeline = AnimationUtil.getSlowFadeOut(failGlyph, fadeDuration, fadeFromValue, 10);
timeline.setOnFinished(event -> {
setTooltip(null);
});
timeline.play();
} else {
setGraphic(null); setGraphic(null);
setTooltip(null); setTooltip(null);
} }
} }
private String getPlatformSleepConfig() {
Platform platform = Platform.getCurrent();
if(platform == Platform.OSX) {
return "OSX System Preferences";
} else if(platform == Platform.WINDOWS) {
return "Windows Control Panel";
}
return "system power settings";
}
private void setMixProgress(UtxoEntry utxoEntry, MixProgress mixProgress) {
if(mixProgress.getMixStep() != MixStep.FAIL) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(mixProgress.getMixStep().getProgressPercent() == 100 ? -1 : mixProgress.getMixStep().getProgressPercent() / 100.0);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
String status = mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase(Locale.ROOT) + mixProgress.getMixStep().getMessage().substring(1);
tt.setText(status);
setTooltip(tt);
if(mixProgress.getMixStep() == MixStep.REGISTERED_INPUT) {
tt.setOnShowing(event -> {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
Whirlpool.RegisteredInputsService registeredInputsService = new Whirlpool.RegisteredInputsService(whirlpool, mixProgress.getPoolId());
registeredInputsService.setOnSucceeded(eventStateHandler -> {
if(registeredInputsService.getValue() != null) {
tt.setText(status + " (1 of " + registeredInputsService.getValue() + ")");
}
});
registeredInputsService.start();
});
}
} else {
setGraphic(null);
setTooltip(null);
}
}
private ProgressIndicator getProgressIndicator() {
ProgressIndicator progressIndicator;
if(getGraphic() instanceof ProgressIndicator) {
progressIndicator = (ProgressIndicator)getGraphic();
} else {
progressIndicator = new ProgressBar();
}
return progressIndicator;
}
private static Glyph getMixGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getStopGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.STOP_CIRCLE);
copyGlyph.setFontSize(12);
return copyGlyph;
}
public static Glyph getFailGlyph() {
Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
failGlyph.getStyleClass().add("fail-warning");
failGlyph.setFontSize(12);
return failGlyph;
}
private static class MixStatusContextMenu extends ContextMenu {
public MixStatusContextMenu(UtxoEntry utxoEntry, boolean isMixing) {
Whirlpool pool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
if(isMixing) {
MenuItem mixStop = new MenuItem("Stop Mixing");
if(pool != null) {
mixStop.disableProperty().bind(pool.mixingProperty().not());
}
mixStop.setGraphic(getStopGlyph());
mixStop.setOnAction(event -> {
hide();
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
if(whirlpool != null) {
try {
whirlpool.mixStop(utxoEntry.getHashIndex());
} catch(WhirlpoolException e) {
AppServices.showErrorDialog("Error stopping mixing UTXO", e.getMessage());
}
}
});
getItems().add(mixStop);
} else {
MenuItem mixNow = new MenuItem("Mix Now");
if(pool != null) {
mixNow.disableProperty().bind(pool.mixingProperty().not());
}
mixNow.setGraphic(getMixGlyph());
mixNow.setOnAction(event -> {
hide();
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
if(whirlpool != null) {
try {
whirlpool.mix(utxoEntry.getHashIndex());
} catch(WhirlpoolException e) {
AppServices.showErrorDialog("Error mixing UTXO", e.getMessage());
}
}
});
getItems().add(mixNow);
}
}
}
} }

View file

@ -0,0 +1,429 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.FileChooser;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.stream.Collectors;
public class MnemonicGridDialog extends Dialog<List<String>> {
private final SpreadsheetView spreadsheetView;
private final int GRID_COLUMN_COUNT = 16;
private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false);
private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false);
private final ObservableList<TablePosition> selectedCells = FXCollections.observableArrayList();
public MnemonicGridDialog() {
DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
setTitle("Border Wallets Grid");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm());
dialogPane.setHeaderText("Load a Border Wallets PDF, or generate a grid from a BIP39 seed.\nThen select 11 or 23 words in a pattern on the grid.\nThe order of selection is important!");
dialogPane.setGraphic(new DialogImage(DialogImage.Type.BORDERWALLETS));
String[][] emptyWordGrid = new String[128][GRID_COLUMN_COUNT];
Grid grid = getGrid(emptyWordGrid);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.setId("grid");
spreadsheetView.setEditable(false);
spreadsheetView.setFixingColumnsAllowed(false);
spreadsheetView.setFixingRowsAllowed(false);
spreadsheetView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() {
@Override
public void onChanged(Change<? extends TablePosition> c) {
while(c.next()) {
if(c.wasAdded()) {
for(TablePosition<?, ?> pos : c.getAddedSubList()) {
if(selectedCells.contains(pos)) {
selectedCells.remove(pos);
} else {
selectedCells.add(pos);
}
}
}
}
int numWords = selectedCells.size();
wordsSelectedProperty.set(numWords == 11 || numWords == 23);
}
});
selectedCells.addListener((ListChangeListener<? super TablePosition>) c -> {
while(c.next()) {
if(c.wasRemoved()) {
for(TablePosition<?,?> pos : c.getRemoved()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().remove("selection");
cell.setGraphic(null);
}
}
if(c.wasAdded()) {
for(TablePosition<?,?> pos : c.getAddedSubList()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().add("selection");
}
}
for(int i = 0; i < selectedCells.size(); i++) {
Text index = new Text(Integer.toString(i+1));
index.setFont(Font.font(8));
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(selectedCells.get(i).getRow()).get(selectedCells.get(i).getColumn());
cell.setGraphic(index);
}
}
});
StackPane stackPane = new StackPane();
stackPane.getChildren().add(spreadsheetView);
dialogPane.setContent(stackPane);
stackPane.widthProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
for(SpreadsheetColumn column : spreadsheetView.getColumns()) {
column.setPrefWidth((newValue.doubleValue() - spreadsheetView.getRowHeaderWidth() - 3) / 17);
}
}
});
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate Grid...", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().add(generateButtonType);
final ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear Selection", ButtonBar.ButtonData.OTHER);
dialogPane.getButtonTypes().add(clearButtonType);
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
okButton.disableProperty().bind(Bindings.not(Bindings.and(initializedProperty, wordsSelectedProperty)));
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null;
});
dialogPane.setPrefWidth(952);
dialogPane.setPrefHeight(500);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
private Grid getGrid(String[][] wordGrid) {
int rowCount = wordGrid.length;
int columnCount = wordGrid[0].length;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
grid.getColumnHeaders().setAll("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P");
for(int i = 0; i < rowCount; i++) {
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
for(int j = 0; j < columnCount; j++) {
list.add(createCell(i, j, wordGrid[i][j]));
}
rows.add(list);
grid.getRowHeaders().add(String.format("%03d", i + 1));
}
grid.setRows(rows);
return grid;
}
private SpreadsheetCell createCell(int row, int column, String word) {
return SpreadsheetCellType.STRING.createCell(row, column, 1, 1, word == null ? "" : word);
}
private List<String> getSelectedWords() {
List<String> abbreviations = selectedCells.stream()
.map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList());
boolean isInteger = abbreviations.stream().allMatch(Utils::isNumber);
boolean isHex = abbreviations.stream().allMatch(Utils::isHex);
List<String> words = new ArrayList<>();
for(String abbreviation : abbreviations) {
if(isInteger) {
try {
int index = Integer.parseInt(abbreviation);
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
} catch(NumberFormatException e) {
//ignore
}
} else if(isHex) {
try {
int index = Integer.parseInt(abbreviation, 16);
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
} catch(NumberFormatException e) {
//ignore
}
} else {
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
words.add(word);
}
}
}
}
if(words.size() != abbreviations.size()) {
abbreviations.removeIf(abbr -> words.stream().anyMatch(w -> w.startsWith(abbr)));
throw new IllegalStateException("Could not find words for abbreviations: " + abbreviations);
}
return words;
}
public List<String> shuffle(List<String> mnemonic) {
String mnemonicString = String.join(" ", mnemonic);
List<String> words = new ArrayList<>(Bip39MnemonicCode.INSTANCE.getWordList());
UltraHighEntropyPrng uhePrng = new UltraHighEntropyPrng();
uhePrng.initState();
uhePrng.hashString(mnemonicString);
for(int i = words.size() - 1; i > 0; i--) {
int j = (int)uhePrng.random(i + 1);
String tmp = words.get(i);
words.set(i, words.get(j));
words.set(j, tmp);
}
return words;
}
private String[][] toGrid(List<String> words) {
String[][] grid = new String[words.size()/GRID_COLUMN_COUNT][GRID_COLUMN_COUNT];
int row = 0;
int col = 0;
for(String word : words) {
String abbr = word.length() < 4 ? word : word.substring(0, 4);
grid[row][col] = abbr;
col++;
if(col >= GRID_COLUMN_COUNT) {
col = 0;
row++;
}
}
return grid;
}
private class MnemonicGridDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open PDF");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", OsType.getCurrent().equals(OsType.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("PDF", "*.pdf")
);
AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450);
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
String[][] wordGrid = PdfUtils.getWordGrid(inputStream);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
} catch(Exception e) {
AppServices.showErrorDialog("Cannot load PDF", e.getMessage());
}
}
});
button = loadButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
SeedEntryDialog seedEntryDialog = new SeedEntryDialog("Border Wallets Entropy Grid Recovery Seed", 12);
seedEntryDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
List<String> mnemonicWords = optWords.get();
List<String> shuffledWordList = shuffle(mnemonicWords);
String[][] wordGrid = toGrid(shuffledWordList);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
if(seedEntryDialog.isGenerated()) {
PdfUtils.saveWordGrid(wordGrid, mnemonicWords);
}
}
});
button = generateButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OTHER) {
Button clearButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(clearButton, buttonData);
clearButton.setOnAction(event -> {
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
});
button = clearButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class UltraHighEntropyPrng {
private final int order;
private double carry;
private int phase;
private final double[] intermediates;
private int i, j, k; // general purpose locals
public UltraHighEntropyPrng() {
order = 48; // set the 'order' number of ENTROPY-holding 32-bit values
carry = 1; // init the 'carry' used by the multiply-with-carry (MWC) algorithm
phase = order; // init the 'phase' (max-1) of the intermediate variable pointer
intermediates = new double[order]; // declare our intermediate variables array
for(i = 0; i < order; i++) {
// Used to simulate javascript's Math.random
Random random = new Random();
intermediates[i] = mash(random.nextInt(Integer.MAX_VALUE)); // fill the array with initial mash hash values
}
}
public double random(int range) {
return Math.floor(range * (rawPrng() + ((long)(rawPrng() * 0x200000L)) * 1.1102230246251565e-16)); // 2^-53
}
private String randomString(int count) {
StringBuilder stringBuilder = new StringBuilder();
for(i = 0; i < count; i++) {
char newChar = (char) (33 + random(94));
stringBuilder.append(newChar);
}
return stringBuilder.toString();
}
private double rawPrng() {
if(++phase >= order) {
phase = 0;
}
double t = 1768863 * intermediates[phase] + carry * 2.3283064365386963e-10; // 2^-32
long temp = (long)t;
return intermediates[phase] = t - (carry = temp);
}
private void hash(String args) {
for(i = 0; i < args.length(); i++) {
for(j = 0; j < order; j++) {
intermediates[j] -= mash(args.charAt(i));
if(intermediates[j] < 0) {
intermediates[j] = intermediates[j] + 1;
}
}
}
}
public void hashString(String input) {
mash(input); // use the string to evolve the 'mash' state
char[] inputAry = input.toCharArray();
for(i = 0; i < inputAry.length; i++) // scan through the characters in our string
{
k = inputAry[i]; // get the character code at the location
for(j = 0; j < order; j++) // "mash" it into the UHEPRNG state
{
intermediates[j] -= mash(k);
if(intermediates[j] < 0) {
intermediates[j] += 1;
}
}
}
}
public void initState() {
mash(null);
for(i = 0; i < order; i++) {
intermediates[i] = mash(' '); // fill the array with initial mash hash values
}
carry = 1; // init our multiply-with-carry carry
phase = order; // init our phase
}
double n = Integer.toUnsignedLong(0xefc8249d);
private double mash(Object data) {
if(data != null) {
String strData = data.toString();
for(int i = 0; i < strData.length(); i++) {
n += strData.charAt(i);
double h = 0.02519603282416938 * n;
n = Integer.toUnsignedLong((int)h);
h -= n;
h *= n;
n = Integer.toUnsignedLong((int)h);
h -= n;
n += h * 0x100000000L; // 2^32
}
return ((long)n) * 2.3283064365386963e-10; // 2^-32
} else {
n = Integer.toUnsignedLong(0xefc8249d);
}
return n;
}
}
}

View file

@ -16,20 +16,24 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane { public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
private final DeterministicSeed.Type type;
public MnemonicKeystoreDisplayPane(Keystore keystore) { public MnemonicKeystoreDisplayPane(Keystore keystore) {
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() && (keystore.getSeed().getPassphrase() == null || keystore.getSeed().getPassphrase().length() > 0) ? "Passphrase entered" : "No passphrase", "", "image/" + WalletModel.SEED.getType() + ".png"); super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() && (keystore.getSeed().getPassphrase() == null || keystore.getSeed().getPassphrase().length() > 0) ? "Passphrase entered" : "No passphrase", "", WalletModel.SEED);
showHideLink.setVisible(false); showHideLink.setVisible(false);
buttonBox.getChildren().clear(); buttonBox.getChildren().clear();
this.type = keystore.getSeed().getType();
showWordList(keystore.getSeed()); showWordList(keystore.getSeed());
} }
protected Node getMnemonicWordsEntry(int numWords) { @Override
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox(); VBox vBox = new VBox();
vBox.setSpacing(10); vBox.setSpacing(10);
wordsPane = new TilePane(); wordsPane = new TilePane();
wordsPane.setPrefRows(numWords / 3); wordsPane.setPrefRows(Math.ceilDiv(numWords, 3));
wordsPane.setHgap(10); wordsPane.setHgap(10);
wordsPane.setVgap(10); wordsPane.setVgap(10);
wordsPane.setOrientation(Orientation.VERTICAL); wordsPane.setOrientation(Orientation.VERTICAL);
@ -43,7 +47,7 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
wordEntriesProperty = new SimpleListProperty<>(wordEntryList); wordEntriesProperty = new SimpleListProperty<>(wordEntryList);
List<WordEntry> wordEntries = new ArrayList<>(numWords); List<WordEntry> wordEntries = new ArrayList<>(numWords);
for(int i = 0; i < numWords; i++) { for(int i = 0; i < numWords; i++) {
wordEntries.add(new WordEntry(i, wordEntryList)); wordEntries.add(new WordEntry(i, wordEntryList, getWordlistProvider()));
} }
for(int i = 0; i < numWords - 1; i++) { for(int i = 0; i < numWords - 1; i++) {
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1)); wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
@ -57,4 +61,9 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
stackPane.getChildren().add(vBox); stackPane.getChildren().add(vBox);
return stackPane; return stackPane;
} }
@Override
protected WordlistProvider getWordlistProvider() {
return getWordListProvider(type);
}
} }

View file

@ -0,0 +1,95 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node;
import javafx.scene.control.Button;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.List;
public class MnemonicKeystoreEntryPane extends MnemonicKeystorePane {
private final BooleanProperty validProperty = new SimpleBooleanProperty(false);
private boolean generated;
public MnemonicKeystoreEntryPane(String name, int numWords) {
super(name, "Enter seed words", "", WalletModel.SEED);
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
defaultWordSizeProperty.set(numWords);
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords, false, true));
setExpanded(true);
}
@Override
protected List<Node> createRightButtons() {
Button button = new Button("Next");
button.setVisible(false);
return List.of(button);
}
@Override
protected void onWordChange(boolean empty, boolean validWords, boolean validChecksum) {
if(!empty && validWords) {
try {
Bip39MnemonicCode.INSTANCE.check(wordEntriesProperty.get());
validChecksum = true;
} catch(MnemonicException e) {
invalidLabel.setText("Invalid checksum");
invalidLabel.setTooltip(null);
}
}
validProperty.set(validChecksum);
validLabel.setVisible(validChecksum);
invalidLabel.setVisible(!validChecksum && !empty);
}
public void generateNew() {
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
SecureRandom secureRandom;
try {
secureRandom = SecureRandom.getInstanceStrong();
} catch(NoSuchAlgorithmException e) {
secureRandom = new SecureRandom();
}
DeterministicSeed deterministicSeed = new DeterministicSeed(secureRandom, entropyLength, "");
displayMnemonicCode(deterministicSeed);
generated = true;
}
private void displayMnemonicCode(DeterministicSeed deterministicSeed) {
setDescription("Write down these words");
showHideLink.setVisible(false);
for (int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(deterministicSeed.getMnemonicCode().get(i));
wordEntry.getEditor().setEditable(false);
}
}
public boolean isValid() {
return validProperty.get();
}
public BooleanProperty validProperty() {
return validProperty;
}
public boolean isGenerated() {
return generated;
}
}

View file

@ -33,6 +33,7 @@ import java.util.Optional;
public class MnemonicKeystoreImportPane extends MnemonicKeystorePane { public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
protected final Wallet wallet; protected final Wallet wallet;
private final KeystoreMnemonicImport importer; private final KeystoreMnemonicImport importer;
private final KeyDerivation defaultDerivation;
private SplitMenuButton importButton; private SplitMenuButton importButton;
@ -43,10 +44,11 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private Button confirmButton; private Button confirmButton;
private List<String> generatedMnemonicCode; private List<String> generatedMnemonicCode;
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer) { public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Create or enter seed", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png"); super(importer.getName(), "Create or enter seed", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet; this.wallet = wallet;
this.importer = importer; this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton(); createImportButton();
buttonBox.getChildren().add(importButton); buttonBox.getChildren().add(importButton);
@ -59,7 +61,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton.getStyleClass().add("default-button"); importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> { importButton.setOnAction(event -> {
importButton.setDisable(true); importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation(), false); importKeystore(getDefaultDerivation(), false);
}); });
String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"}; String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length; int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
@ -77,6 +79,10 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton.setVisible(false); importButton.setVisible(false);
} }
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
protected void enterMnemonic(int numWords) { protected void enterMnemonic(int numWords) {
generatedMnemonicCode = null; generatedMnemonicCode = null;
super.enterMnemonic(numWords); super.enterMnemonic(numWords);
@ -223,7 +229,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private void confirmBackup() { private void confirmBackup() {
setDescription("Confirm backup by re-entering words"); setDescription("Confirm backup by re-entering words");
showHideLink.setVisible(false); showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size())); setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), true, false));
setExpanded(true); setExpanded(true);
backButton.setVisible(true); backButton.setVisible(true);
generateButton.setVisible(false); generateButton.setVisible(false);
@ -243,7 +249,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
setDescription("Ready to import"); setDescription("Ready to import");
showHideLink.setText("Show Derivation..."); showHideLink.setText("Show Derivation...");
showHideLink.setVisible(false); showHideLink.setVisible(false);
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation())); setContent(getDerivationEntry(getDefaultDerivation()));
} }
} }
@ -254,6 +260,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
if(!dryrun) { if(!dryrun) {
if(passphraseProperty.get() != null && !passphraseProperty.get().isEmpty()) { if(passphraseProperty.get() != null && !passphraseProperty.get().isEmpty()) {
KeystorePassphraseDialog keystorePassphraseDialog = new KeystorePassphraseDialog(null, keystore, true); KeystorePassphraseDialog keystorePassphraseDialog = new KeystorePassphraseDialog(null, keystore, true);
keystorePassphraseDialog.initOwner(this.getScene().getWindow());
Optional<String> optPassphrase = keystorePassphraseDialog.showAndWait(); Optional<String> optPassphrase = keystorePassphraseDialog.showAndWait();
if(optPassphrase.isEmpty() || !optPassphrase.get().equals(passphraseProperty.get())) { if(optPassphrase.isEmpty() || !optPassphrase.get().equals(passphraseProperty.get())) {
throw new ImportException("Re-entered passphrase did not match"); throw new ImportException("Re-entered passphrase did not match");

View file

@ -2,8 +2,11 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode; import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed; import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.drongo.wallet.slip39.Slip39MnemonicCode;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.IntegerProperty; import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleIntegerProperty;
@ -12,6 +15,8 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList; import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Orientation; import javafx.geometry.Orientation;
import javafx.geometry.Pos; import javafx.geometry.Pos;
@ -20,8 +25,8 @@ import javafx.scene.control.*;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.layout.*; import javafx.scene.layout.*;
import javafx.util.Callback; import javafx.util.Callback;
import javafx.util.Duration;
import org.controlsfx.control.textfield.AutoCompletionBinding; import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields; import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph; import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult; import org.controlsfx.validation.ValidationResult;
@ -44,11 +49,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected Label invalidLabel; protected Label invalidLabel;
protected SimpleListProperty<String> wordEntriesProperty; protected SimpleListProperty<String> wordEntriesProperty;
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty(); protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty("");
protected IntegerProperty defaultWordSizeProperty; protected IntegerProperty defaultWordSizeProperty;
public MnemonicKeystorePane(String title, String description, String content, String imageUrl) { public MnemonicKeystorePane(String title, String description, String content, WalletModel walletModel) {
super(title, description, content, imageUrl); super(title, description, content, walletModel);
} }
@Override @Override
@ -79,6 +84,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.getItems().add(item); enterMnemonicButton.getItems().add(item);
} }
enterMnemonicButton.getItems().add(new SeparatorMenuItem()); enterMnemonicButton.getItems().add(new SeparatorMenuItem());
MenuItem gridItem = new MenuItem("Border Wallets Grid...");
gridItem.setOnAction(event -> {
showGrid();
});
enterMnemonicButton.getItems().add(gridItem);
MenuItem scanItem = new MenuItem("Scan QR..."); MenuItem scanItem = new MenuItem("Scan QR...");
scanItem.setOnAction(event -> { scanItem.setOnAction(event -> {
scanQR(); scanQR();
@ -87,8 +98,33 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty()); enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
} }
protected void showGrid() {
MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog();
mnemonicGridDialog.initOwner(this.getScene().getWindow());
Optional<List<String>> optWords = mnemonicGridDialog.showAndWait();
if(optWords.isPresent()) {
List<String> words = optWords.get();
defaultWordSizeProperty.set(words.size() + 1);
setContent(getMnemonicWordsEntry(words.size() + 1, true, true));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
if(i < words.size()) {
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
} else {
AppServices.runAfterDelay(500, () -> {
Platform.runLater(() -> wordEntry.getEditor().requestFocus());
});
}
}
}
}
protected void scanQR() { protected void scanQR() {
QRScanDialog qrScanDialog = new QRScanDialog(); QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(this.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait(); Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) { if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get(); QRScanDialog.Result result = optionalResult.get();
@ -106,7 +142,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void showWordList(DeterministicSeed seed) { protected void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode(); List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size())); showWordList(words);
}
protected void showWordList(List<String> words) {
setContent(getMnemonicWordsEntry(words.size(), true, true));
setExpanded(true); setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) { for(int i = 0; i < wordsPane.getChildren().size(); i++) {
@ -119,16 +159,16 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void enterMnemonic(int numWords) { protected void enterMnemonic(int numWords) {
setDescription("Generate new or enter existing"); setDescription("Generate new or enter existing");
showHideLink.setVisible(false); showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords)); setContent(getMnemonicWordsEntry(numWords, true, true));
setExpanded(true); setExpanded(true);
} }
protected Node getMnemonicWordsEntry(int numWords) { protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox(); VBox vBox = new VBox();
vBox.setSpacing(10); vBox.setSpacing(10);
wordsPane = new TilePane(); wordsPane = new TilePane();
wordsPane.setPrefRows(numWords/3); wordsPane.setPrefRows(Math.ceilDiv(numWords, 3));
wordsPane.setHgap(10); wordsPane.setHgap(10);
wordsPane.setVgap(10); wordsPane.setVgap(10);
wordsPane.setOrientation(Orientation.VERTICAL); wordsPane.setOrientation(Orientation.VERTICAL);
@ -142,7 +182,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
wordEntriesProperty = new SimpleListProperty<>(wordEntryList); wordEntriesProperty = new SimpleListProperty<>(wordEntryList);
List<WordEntry> wordEntries = new ArrayList<>(numWords); List<WordEntry> wordEntries = new ArrayList<>(numWords);
for(int i = 0; i < numWords; i++) { for(int i = 0; i < numWords; i++) {
wordEntries.add(new WordEntry(i, wordEntryList)); wordEntries.add(new WordEntry(i, wordEntryList, getWordlistProvider()));
} }
for(int i = 0; i < numWords - 1; i++) { for(int i = 0; i < numWords - 1; i++) {
wordEntries.get(i).setNextEntry(wordEntries.get(i + 1)); wordEntries.get(i).setNextEntry(wordEntries.get(i + 1));
@ -152,10 +192,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
vBox.getChildren().add(wordsPane); vBox.getChildren().add(wordsPane);
PassphraseEntry passphraseEntry = new PassphraseEntry(); if(showPassphrase) {
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor()); PassphraseEntry passphraseEntry = new PassphraseEntry(editPassphrase);
passphraseEntry.setPadding(new Insets(0, 26, 10, 10)); wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
vBox.getChildren().add(passphraseEntry); passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
}
AnchorPane buttonPane = new AnchorPane(); AnchorPane buttonPane = new AnchorPane();
buttonPane.setPadding(new Insets(0, 26, 0, 10)); buttonPane.setPadding(new Insets(0, 26, 0, 10));
@ -166,7 +208,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
buttonPane.getChildren().add(leftBox); buttonPane.getChildren().add(leftBox);
AnchorPane.setLeftAnchor(leftBox, 0.0); AnchorPane.setLeftAnchor(leftBox, 0.0);
validLabel = new Label("Valid checksum", getValidGlyph()); validLabel = new Label("Valid checksum", GlyphUtils.getSuccessGlyph());
validLabel.setContentDisplay(ContentDisplay.LEFT); validLabel.setContentDisplay(ContentDisplay.LEFT);
validLabel.setGraphicTextGap(5.0); validLabel.setGraphicTextGap(5.0);
validLabel.managedProperty().bind(validLabel.visibleProperty()); validLabel.managedProperty().bind(validLabel.visibleProperty());
@ -175,7 +217,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
AnchorPane.setTopAnchor(validLabel, 5.0); AnchorPane.setTopAnchor(validLabel, 5.0);
AnchorPane.setLeftAnchor(validLabel, 0.0); AnchorPane.setLeftAnchor(validLabel, 0.0);
invalidLabel = new Label("Invalid checksum", getInvalidGlyph()); invalidLabel = new Label("Invalid checksum", GlyphUtils.getInvalidGlyph());
invalidLabel.setContentDisplay(ContentDisplay.LEFT); invalidLabel.setContentDisplay(ContentDisplay.LEFT);
invalidLabel.setGraphicTextGap(5.0); invalidLabel.setGraphicTextGap(5.0);
invalidLabel.managedProperty().bind(invalidLabel.visibleProperty()); invalidLabel.managedProperty().bind(invalidLabel.visibleProperty());
@ -193,7 +235,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
empty = false; empty = false;
} }
if(!WordEntry.isValid(word)) { if(!getWordlistProvider().isValid(word)) {
validWords = false; validWords = false;
} }
} }
@ -229,13 +271,20 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
//nothing by default //nothing by default
} }
protected WordlistProvider getWordlistProvider() {
return getWordListProvider(DeterministicSeed.Type.BIP39);
}
protected WordlistProvider getWordListProvider(DeterministicSeed.Type type) {
return type == DeterministicSeed.Type.SLIP39 ? new Slip39WordlistProvider() : new Bip39WordlistProvider();
}
protected static class WordEntry extends HBox { protected static class WordEntry extends HBox {
private static List<String> wordList;
private final TextField wordField; private final TextField wordField;
private WordEntry nextEntry; private WordEntry nextEntry;
private TextField nextField; private TextField nextField;
public WordEntry(int wordNumber, ObservableList<String> wordEntryList) { public WordEntry(int wordNumber, ObservableList<String> wordEntryList, WordlistProvider wordlistProvider) {
super(); super();
setAlignment(Pos.CENTER_RIGHT); setAlignment(Pos.CENTER_RIGHT);
@ -250,9 +299,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) { if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) {
String[] words = clipboard.getString().split("[\\n\\s]"); String[] words = clipboard.getString().split("[\\n\\s]");
WordEntry entry = WordEntry.this; WordEntry entry = WordEntry.this;
for(String word : words) { for(int i = 0; i < words.length; i++) {
String word = words[i];
if(entry.nextField != null) { if(entry.nextField != null) {
entry.nextField.requestFocus(); if(i == words.length - 2 && wordlistProvider.isValid(word)) {
label.requestFocus();
} else {
entry.nextField.requestFocus();
}
} }
entry.wordField.setText(word); entry.wordField.setText(word);
@ -267,6 +321,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
} }
}; };
wordField.setMaxWidth(100); wordField.setMaxWidth(100);
wordField.setAccessibleText("Word " + (wordNumber + 1));
TextFormatter<?> formatter = new TextFormatter<>((TextFormatter.Change change) -> { TextFormatter<?> formatter = new TextFormatter<>((TextFormatter.Change change) -> {
String text = change.getText(); String text = change.getText();
// if text was added, fix the text to fit the requirements // if text was added, fix the text to fit the requirements
@ -281,8 +336,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
}); });
wordField.setTextFormatter(formatter); wordField.setTextFormatter(formatter);
wordList = Bip39MnemonicCode.INSTANCE.getWordList(); AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordlistProvider, wordNumber, wordEntryList));
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
autoCompletionBinding.setDelay(50); autoCompletionBinding.setDelay(50);
autoCompletionBinding.setOnAutoCompleted(event -> { autoCompletionBinding.setOnAutoCompleted(event -> {
if(nextField != null) { if(nextField != null) {
@ -290,11 +344,20 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
} }
}); });
//Show autocomplete for the last word on focus if empty
boolean lastWord = wordNumber == wordEntryList.size() - 1;
if(lastWord) {
wordField.focusedProperty().addListener((observable, oldValue, focused) -> {
if(focused && wordField.getText().isEmpty()) {
autoCompletionBinding.setUserInput("");
}
});
}
ValidationSupport validationSupport = new ValidationSupport(); ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(wordField, Validator.combine( validationSupport.registerValidator(wordField, Validator.combine(
Validator.createEmptyValidator("Word is required"), (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", (newValue.length() > 0 || !lastWord) && !wordlistProvider.isValid(newValue))
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
)); ));
wordField.textProperty().addListener((observable, oldValue, newValue) -> { wordField.textProperty().addListener((observable, oldValue, newValue) -> {
@ -315,24 +378,37 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
public void setNextField(TextField field) { public void setNextField(TextField field) {
this.nextField = field; this.nextField = field;
} }
public static boolean isValid(String word) {
return wordList.contains(word);
}
} }
protected static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> { protected static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> {
private final List<String> wordList; private final WordlistProvider wordlistProvider;
private final int wordNumber;
private final ObservableList<String> wordEntryList;
public WordlistSuggestionProvider(List<String> wordList) { public WordlistSuggestionProvider(WordlistProvider wordlistProvider, int wordNumber, ObservableList<String> wordEntryList) {
this.wordList = wordList; this.wordlistProvider = wordlistProvider;
this.wordNumber = wordNumber;
this.wordEntryList = wordEntryList;
} }
@Override @Override
public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) { public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) {
if(wordlistProvider.supportsPossibleLastWords() && wordNumber == wordEntryList.size() - 1 && allPreviousWordsValid()) {
try {
List<String> possibleLastWords = wordlistProvider.getPossibleLastWords(wordEntryList.subList(0, wordEntryList.size() - 1));
if(!request.getUserText().isEmpty()) {
possibleLastWords.removeIf(s -> !s.startsWith(request.getUserText()));
}
return possibleLastWords;
} catch(Exception e) {
log.warn("Cannot determine possible last words", e);
}
}
List<String> suggestions = new ArrayList<>(); List<String> suggestions = new ArrayList<>();
if(!request.getUserText().isEmpty()) { if(!request.getUserText().isEmpty()) {
for(String word : wordList) { for(String word : wordlistProvider.getWordlist()) {
if(word.startsWith(request.getUserText())) { if(word.startsWith(request.getUserText())) {
suggestions.add(word); suggestions.add(word);
} }
@ -341,26 +417,63 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
return suggestions; return suggestions;
} }
private boolean allPreviousWordsValid() {
for(int i = 0; i < wordEntryList.size() - 1; i++) {
if(!wordlistProvider.isValid(wordEntryList.get(i))) {
return false;
}
}
return true;
}
} }
protected class PassphraseEntry extends HBox { protected class PassphraseEntry extends HBox {
private final CustomTextField passphraseField; private final TextField passphraseField;
public PassphraseEntry() { public PassphraseEntry(boolean editable) {
super(); super();
setAlignment(Pos.CENTER_LEFT); setAlignment(Pos.CENTER_LEFT);
setSpacing(10); setSpacing(10);
Label usePassphraseLabel = new Label("Use passphrase?");
usePassphraseLabel.managedProperty().bind(usePassphraseLabel.visibleProperty());
CheckBox usePassphraseCheckbox = new CheckBox(" ");
usePassphraseCheckbox.setDisable(!editable);
usePassphraseCheckbox.managedProperty().bind(usePassphraseCheckbox.visibleProperty());
usePassphraseLabel.visibleProperty().bind(usePassphraseCheckbox.visibleProperty());
usePassphraseCheckbox.setVisible(passphraseProperty.isEmpty().get());
usePassphraseCheckbox.selectedProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Add a passphrase?", "A passphrase is not a password! " +
"Any variation entered in future loads a valid wallet, but with different addresses.\n\n" +
"This feature provides optional added security for advanced users only. Are you sure?", ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.YES) {
usePassphraseCheckbox.setVisible(false);
} else {
usePassphraseCheckbox.setSelected(false);
}
}
});
Label passphraseLabel = new Label("Passphrase:"); Label passphraseLabel = new Label("Passphrase:");
passphraseField = (CustomTextField) TextFields.createClearableTextField(); passphraseLabel.managedProperty().bind(passphraseLabel.visibleProperty());
passphraseField = new TextField();
passphraseField.setPromptText(passphraseProperty.isEmpty().get() ? "Leave blank for none" : "");
passphraseField.setText(passphraseProperty.get());
passphraseField.setDisable(!editable);
passphraseField.managedProperty().bind(passphraseField.visibleProperty());
passphraseLabel.visibleProperty().bind(passphraseField.visibleProperty());
passphraseField.visibleProperty().bind(usePassphraseCheckbox.visibleProperty().not());
passphraseProperty.bind(passphraseField.textProperty()); passphraseProperty.bind(passphraseField.textProperty());
passphraseField.setPromptText("Leave blank for none");
HelpLabel helpLabel = new HelpLabel(); HelpLabel helpLabel = new HelpLabel();
helpLabel.setPrefHeight(28);
helpLabel.setStyle("-fx-padding: 0 0 0 0"); helpLabel.setStyle("-fx-padding: 0 0 0 0");
helpLabel.setHelpText("A passphrase provides optional added security - it is not stored so it must be remembered!"); helpLabel.setHelpText("Advanced feature: a passphrase provides optional added security, but it is not stored so it must be remembered!");
getChildren().addAll(passphraseLabel, passphraseField, helpLabel); getChildren().addAll(usePassphraseLabel, usePassphraseCheckbox, passphraseLabel, passphraseField, helpLabel);
} }
public TextField getEditor() { public TextField getEditor() {
@ -368,17 +481,53 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
} }
} }
public static Glyph getValidGlyph() { protected interface WordlistProvider {
Glyph validGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE); List<String> getWordlist();
validGlyph.getStyleClass().add("success"); boolean isValid(String word);
validGlyph.setFontSize(12); boolean supportsPossibleLastWords();
return validGlyph; List<String> getPossibleLastWords(List<String> previousWords) throws MnemonicException.MnemonicLengthException, MnemonicException.MnemonicWordException;
} }
public static Glyph getInvalidGlyph() { private static class Bip39WordlistProvider implements WordlistProvider {
Glyph invalidGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE); @Override
invalidGlyph.getStyleClass().add("failure"); public List<String> getWordlist() {
invalidGlyph.setFontSize(12); return Bip39MnemonicCode.INSTANCE.getWordList();
return invalidGlyph; }
public boolean isValid(String word) {
return getWordlist().contains(word);
}
@Override
public boolean supportsPossibleLastWords() {
return true;
}
@Override
public List<String> getPossibleLastWords(List<String> previousWords) throws MnemonicException.MnemonicLengthException, MnemonicException.MnemonicWordException {
return Bip39MnemonicCode.INSTANCE.getPossibleLastWords(previousWords);
}
}
private static class Slip39WordlistProvider implements WordlistProvider {
@Override
public List<String> getWordlist() {
return Slip39MnemonicCode.INSTANCE.getWordList();
}
@Override
public boolean isValid(String word) {
return getWordlist().contains(word);
}
@Override
public boolean supportsPossibleLastWords() {
return false;
}
@Override
public List<String> getPossibleLastWords(List<String> previousWords) {
throw new UnsupportedOperationException();
}
} }
} }

View file

@ -0,0 +1,319 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.slip39.Share;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreMnemonicShareImport;
import com.sparrowwallet.sparrow.io.Slip39;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.util.*;
public class MnemonicShareKeystoreImportPane extends MnemonicKeystorePane {
protected final Wallet wallet;
private final KeystoreMnemonicShareImport importer;
private final KeyDerivation defaultDerivation;
private final List<List<String>> mnemonicShares = new ArrayList<>();
private SplitMenuButton importButton;
private Button calculateButton;
private Button backButton;
private Button nextButton;
private int currentShare;
public MnemonicShareKeystoreImportPane(Wallet wallet, KeystoreMnemonicShareImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Enter seed share", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton();
buttonBox.getChildren().add(importButton);
}
@Override
protected Control createButton() {
createEnterMnemonicButton();
return enterMnemonicButton;
}
private void createEnterMnemonicButton() {
enterMnemonicButton = new SplitMenuButton();
enterMnemonicButton.setAlignment(Pos.CENTER_RIGHT);
enterMnemonicButton.setText("Use 20 Words");
defaultWordSizeProperty = new SimpleIntegerProperty(20);
defaultWordSizeProperty.addListener((observable, oldValue, newValue) -> {
enterMnemonicButton.setText("Use " + newValue + " Words");
});
enterMnemonicButton.setOnAction(event -> {
resetShares();
enterMnemonic(defaultWordSizeProperty.get());
});
int[] numberWords = new int[] {20, 33};
for(int i = 0; i < numberWords.length; i++) {
MenuItem item = new MenuItem("Use " + numberWords[i] + " Words");
final int words = numberWords[i];
item.setOnAction(event -> {
resetShares();
defaultWordSizeProperty.set(words);
enterMnemonic(words);
});
enterMnemonicButton.getItems().add(item);
}
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
}
protected List<Node> createRightButtons() {
calculateButton = new Button("Create Keystore");
calculateButton.setDefaultButton(true);
calculateButton.setOnAction(event -> {
prepareImport();
});
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
calculateButton.setTooltip(new Tooltip("Create the keystore from the provided shares"));
calculateButton.setVisible(false);
backButton = new Button("Back");
backButton.setOnAction(event -> {
lastShare();
});
backButton.managedProperty().bind(backButton.visibleProperty());
backButton.setTooltip(new Tooltip("Display the last share added"));
backButton.setVisible(currentShare > 0);
nextButton = new Button("Next");
nextButton.setOnAction(event -> {
nextShare();
});
nextButton.managedProperty().bind(nextButton.visibleProperty());
nextButton.setTooltip(new Tooltip("Add the next share"));
nextButton.visibleProperty().bind(calculateButton.visibleProperty().not());
nextButton.setDefaultButton(true);
nextButton.setDisable(true);
return List.of(backButton, nextButton, calculateButton);
}
@Override
protected void enterMnemonic(int numWords) {
super.enterMnemonic(numWords);
setDescription("Enter existing share");
}
private void resetShares() {
currentShare = 0;
mnemonicShares.clear();
}
private void lastShare() {
currentShare--;
showWordList(mnemonicShares.get(currentShare));
}
private void nextShare() {
if(currentShare == mnemonicShares.size()) {
mnemonicShares.add(wordEntriesProperty.get());
} else {
mnemonicShares.set(currentShare, wordEntriesProperty.get());
}
currentShare++;
if(currentShare < mnemonicShares.size()) {
showWordList(mnemonicShares.get(currentShare));
} else {
setContent(getMnemonicWordsEntry(defaultWordSizeProperty.get(), true, true));
}
setExpanded(true);
}
protected void onWordChange(boolean empty, boolean validWords, boolean validChecksum) {
boolean validSet = false;
boolean complete = false;
if(!empty && validWords) {
try {
Share.fromMnemonic(String.join(" ", wordEntriesProperty.get()));
validChecksum = true;
List<List<String>> existing = new ArrayList<>(mnemonicShares);
if(currentShare >= mnemonicShares.size()) {
existing.add(wordEntriesProperty.get());
}
importer.getKeystore(wallet.getScriptType().getDefaultDerivation(), existing, passphraseProperty.get());
validSet = true;
complete = true;
} catch(MnemonicException e) {
invalidLabel.setText(e.getTitle());
invalidLabel.setTooltip(new Tooltip(e.getMessage()));
} catch(Slip39.Slip39ProgressException e) {
validSet = true;
invalidLabel.setText(e.getTitle());
invalidLabel.setTooltip(new Tooltip(e.getMessage()));
} catch(ImportException e) {
if(e.getCause() instanceof MnemonicException mnemonicException) {
invalidLabel.setText(mnemonicException.getTitle());
invalidLabel.setTooltip(new Tooltip(mnemonicException.getMessage()));
} else {
invalidLabel.setText("Import Error");
invalidLabel.setTooltip(new Tooltip(e.getMessage()));
}
}
}
calculateButton.setVisible(complete);
backButton.setVisible(currentShare > 0 && !complete);
nextButton.setDisable(!validChecksum || !validSet);
validLabel.setVisible(complete);
validLabel.setText(mnemonicShares.isEmpty() ? "Valid checksum" : "Completed share set");
invalidLabel.setVisible(!complete && !empty);
invalidLabel.setGraphic(validChecksum && validSet ? getIncompleteGlyph() : GlyphUtils.getFailureGlyph());
}
private void createImportButton() {
importButton = new SplitMenuButton();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(getDefaultDerivation(), false);
});
String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(derivation, false);
});
importButton.getItems().add(item);
}
importButton.managedProperty().bind(importButton.visibleProperty());
importButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
private void prepareImport() {
nextShare();
backButton.setVisible(false);
if(importKeystore(wallet.getScriptType().getDefaultDerivation(), true)) {
setExpanded(true);
enterMnemonicButton.setVisible(false);
importButton.setVisible(true);
importButton.setDisable(false);
setDescription("Ready to import");
showHideLink.setText("Show Derivation...");
showHideLink.setVisible(false);
setContent(getDerivationEntry(getDefaultDerivation()));
}
}
private boolean importKeystore(List<ChildNumber> derivation, boolean dryrun) {
importButton.setDisable(true);
try {
Keystore keystore = importer.getKeystore(derivation, mnemonicShares, passphraseProperty.get());
if(!dryrun) {
if(passphraseProperty.get() != null && !passphraseProperty.get().isEmpty()) {
KeystorePassphraseDialog keystorePassphraseDialog = new KeystorePassphraseDialog(null, keystore, true);
keystorePassphraseDialog.initOwner(this.getScene().getWindow());
Optional<String> optPassphrase = keystorePassphraseDialog.showAndWait();
if(optPassphrase.isEmpty() || !optPassphrase.get().equals(passphraseProperty.get())) {
throw new ImportException("Re-entered passphrase did not match");
}
}
EventManager.get().post(new KeystoreImportEvent(keystore));
}
return true;
} catch (ImportException e) {
String errorMessage = e.getMessage();
if(e.getCause() instanceof MnemonicException.MnemonicChecksumException) {
errorMessage = "Invalid word list - checksum incorrect";
} else if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Import Error", errorMessage + ".");
importButton.setDisable(false);
return false;
}
}
private Node getDerivationEntry(List<ChildNumber> derivation) {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
importDerivationButton.setDisable(true);
importDerivationButton.setOnAction(event -> {
showHideLink.setVisible(true);
setExpanded(false);
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
importKeystore(importDerivation, false);
});
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
});
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(derivationField);
contentBox.getChildren().add(importDerivationButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
return contentBox;
}
public static Glyph getIncompleteGlyph() {
Glyph warningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PLUS_CIRCLE);
warningGlyph.getStyleClass().add("warn-icon");
warningGlyph.setFontSize(12);
return warningGlyph;
}
@Override
protected WordlistProvider getWordlistProvider() {
return getWordListProvider(DeterministicSeed.Type.SLIP39);
}
}

View file

@ -10,9 +10,11 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent; import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException; import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreMnemonicImport; import com.sparrowwallet.sparrow.io.KeystoreMnemonicImport;
import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.geometry.Pos; import javafx.geometry.Pos;
@ -39,7 +41,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
private Button importButton; private Button importButton;
public MnemonicWalletKeystoreImportPane(KeystoreMnemonicImport importer) { public MnemonicWalletKeystoreImportPane(KeystoreMnemonicImport importer) {
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png"); super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.importer = importer; this.importer = importer;
} }
@ -53,7 +55,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
protected List<Node> createRightButtons() { protected List<Node> createRightButtons() {
discoverButton = new Button("Discover Wallet"); discoverButton = new Button("Discover Wallet");
discoverButton.setDisable(true); discoverButton.setDisable(true);
discoverButton.setDefaultButton(true); discoverButton.setDefaultButton(AppServices.onlineProperty().get());
discoverButton.managedProperty().bind(discoverButton.visibleProperty()); discoverButton.managedProperty().bind(discoverButton.visibleProperty());
discoverButton.setOnAction(event -> { discoverButton.setOnAction(event -> {
discoverWallet(); discoverWallet();
@ -64,6 +66,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
importButton = new Button("Import Wallet"); importButton = new Button("Import Wallet");
importButton.setDisable(true); importButton.setDisable(true);
importButton.setDefaultButton(!AppServices.onlineProperty().get());
importButton.managedProperty().bind(importButton.visibleProperty()); importButton.managedProperty().bind(importButton.visibleProperty());
importButton.visibleProperty().bind(discoverButton.visibleProperty().not()); importButton.visibleProperty().bind(discoverButton.visibleProperty().not());
importButton.setOnAction(event -> { importButton.setOnAction(event -> {
@ -107,6 +110,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
List<List<ChildNumber>> derivations = ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).stream().map(ScriptType::getDefaultDerivation).collect(Collectors.toList()); List<List<ChildNumber>> derivations = ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).stream().map(ScriptType::getDefaultDerivation).collect(Collectors.toList());
derivations.add(List.of(new ChildNumber(0, true))); derivations.add(List.of(new ChildNumber(0, true)));
derivations.add(ScriptType.P2PKH.getDefaultDerivation(1)); //Bisq segwit misderivation
for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE)) { for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE)) {
for(List<ChildNumber> derivation : derivations) { for(List<ChildNumber> derivation : derivations) {
@ -135,7 +139,10 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
EventManager.get().post(new WalletImportEvent(optWallet.get())); EventManager.get().post(new WalletImportEvent(optWallet.get()));
} else { } else {
discoverButton.setDisable(false); discoverButton.setDisable(false);
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found", "Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES); Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found",
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
"You can however import this wallet and scan the blockchain by supplying a start date. Do you want to import this wallet?" :
"Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) { if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
setContent(getScriptTypeEntry()); setContent(getScriptTypeEntry());
setExpanded(true); setExpanded(true);
@ -183,13 +190,14 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
scriptTypeComboBox.setMaxWidth(170); scriptTypeComboBox.setMaxWidth(170);
HelpLabel helpLabel = new HelpLabel(); HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing."); helpLabel.setHelpText("Native Segwit is usually the best choice for new wallets.\nTaproot is a new type useful for specific needs.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel); fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel);
Region region = new Region(); Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES); HBox.setHgrow(region, Priority.SOMETIMES);
Button importMnemonicButton = new Button("Import"); Button importMnemonicButton = new Button("Import");
importMnemonicButton.setDefaultButton(true);
importMnemonicButton.setOnAction(event -> { importMnemonicButton.setOnAction(event -> {
showHideLink.setVisible(true); showHideLink.setVisible(true);
setExpanded(false); setExpanded(false);

View file

@ -1,14 +1,14 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.TreeTableCell; import javafx.scene.control.TreeTableCell;
import org.controlsfx.tools.Platform;
public class NumberCell extends TreeTableCell<Entry, Number> { public class NumberCell extends TreeTableCell<Entry, Number> {
public NumberCell() { public NumberCell() {
super(); super();
getStyleClass().add("number-cell"); getStyleClass().add("number-cell");
if(Platform.getCurrent() == Platform.OSX) { if(OsType.getCurrent() == OsType.MACOS) {
getStyleClass().add("number-field"); getStyleClass().add("number-field");
} }
} }

View file

@ -20,7 +20,7 @@ import org.slf4j.LoggerFactory;
import java.io.InputStream; import java.io.InputStream;
import java.net.Proxy; import java.net.Proxy;
import java.net.URL; import java.net.URI;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -79,10 +79,6 @@ public class PayNymAvatar extends StackPane {
this.paymentCodeProperty.set(paymentCode); this.paymentCodeProperty.set(paymentCode);
} }
public void setPaymentCode(com.samourai.wallet.bip47.rpc.PaymentCode paymentCode) {
setPaymentCode(PaymentCode.fromString(paymentCode.toString()));
}
public void clearPaymentCode() { public void clearPaymentCode() {
this.paymentCodeProperty.set(null); this.paymentCodeProperty.set(null);
} }
@ -128,8 +124,11 @@ public class PayNymAvatar extends StackPane {
log.debug("Requesting PayNym avatar from " + url); log.debug("Requesting PayNym avatar from " + url);
} }
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) { try(InputStream is = (proxy == null ? new URI(url).toURL().openStream() : new URI(url).toURL().openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false); Image image = new Image(is, 150, 150, true, true);
if(image.getException() != null) {
throw image.getException();
}
paymentCodeCache.put(cacheId, image); paymentCodeCache.put(cacheId, image);
Platform.runLater(() -> EventManager.get().post(new PayNymImageLoadedEvent(paymentCode, image))); Platform.runLater(() -> EventManager.get().post(new PayNymImageLoadedEvent(paymentCode, image)));
return image; return image;

View file

@ -81,10 +81,7 @@ public class PayNymCell extends ListCell<PayNym> {
linkButton.setDisable(true); linkButton.setDisable(true);
payNymController.linkPayNym(payNym); payNymController.linkPayNym(payNym);
}); });
getStyleClass().add("unlinked");
if(payNymController.isSelectLinkedOnly()) {
getStyleClass().add("unlinked");
}
} }
} }
@ -110,6 +107,7 @@ public class PayNymCell extends ListCell<PayNym> {
MenuItem rename = new MenuItem("Rename Contact..."); MenuItem rename = new MenuItem("Rename Contact...");
rename.setOnAction(event -> { rename.setOnAction(event -> {
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact"); WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
walletLabelDialog.initOwner(PayNymCell.this.getScene().getWindow());
Optional<String> optLabel = walletLabelDialog.showAndWait(); Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) { if(optLabel.isPresent()) {
int index = getListView().getItems().indexOf(payNym); int index = getListView().getItems().indexOf(payNym);

View file

@ -10,11 +10,6 @@ public class PaymentCodeTextField extends CopyableTextField {
setPaymentCodeString(); setPaymentCodeString();
} }
public void setPaymentCode(com.samourai.wallet.bip47.rpc.PaymentCode paymentCode) {
this.paymentCodeStr = paymentCode.toString();
setPaymentCodeString();
}
private void setPaymentCodeString() { private void setPaymentCodeString() {
String abbrevPcode = paymentCodeStr.substring(0, 12) + "..." + paymentCodeStr.substring(paymentCodeStr.length() - 5); String abbrevPcode = paymentCodeStr.substring(0, 12) + "..." + paymentCodeStr.substring(paymentCodeStr.length() - 5);
setText(abbrevPcode); setText(abbrevPcode);

View file

@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.Files; import com.google.common.io.Files;
import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.Address;
@ -12,9 +14,16 @@ import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput; import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
@ -42,9 +51,7 @@ import tornadofx.control.Form;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.*;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR; import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
@ -55,8 +62,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private final TextArea key; private final TextArea key;
private final ComboBox<ScriptType> keyScriptType; private final ComboBox<ScriptType> keyScriptType;
private final CopyableLabel keyAddress; private final CopyableLabel keyAddress;
private final CopyableLabel keyUtxos;
private final ComboBoxTextField toAddress; private final ComboBoxTextField toAddress;
private final ComboBox<Wallet> toWallet; private final ComboBox<Wallet> toWallet;
private final FeeRangeSlider feeRange;
private final CopyableLabel feeRate;
public PrivateKeySweepDialog(Wallet wallet) { public PrivateKeySweepDialog(Wallet wallet) {
final DialogPane dialogPane = getDialogPane(); final DialogPane dialogPane = getDialogPane();
@ -64,14 +74,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow()); AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Sweep Private Key"); dialogPane.setHeaderText("Sweep Private Key");
dialogPane.setGraphic(new WalletModelImage(WalletModel.SEED));
Image image = new Image("image/seed.png", 50, 50, false, false);
if(!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
Form form = new Form(); Form form = new Form();
Fieldset fieldset = new Fieldset(); Fieldset fieldset = new Fieldset();
@ -96,6 +99,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
HBox.setHgrow(key, Priority.ALWAYS); HBox.setHgrow(key, Priority.ALWAYS);
keyField.getInputs().add(keyBox); keyField.getInputs().add(keyBox);
if(CardApi.isReaderAvailable()) {
VBox cardButtonBox = new VBox(5);
Button cardKey = new Button("", getGlyph(FontAwesome5.Glyph.WIFI));
cardKey.setOnAction(event -> unsealPrivateKey());
cardButtonBox.getChildren().add(cardKey);
keyBox.getChildren().add(cardButtonBox);
}
Field keyScriptTypeField = new Field(); Field keyScriptTypeField = new Field();
keyScriptTypeField.setText("Script Type:"); keyScriptTypeField.setText("Script Type:");
keyScriptType = new ComboBox<>(); keyScriptType = new ComboBox<>();
@ -120,6 +131,12 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
keyAddress.getStyleClass().add("fixed-width"); keyAddress.getStyleClass().add("fixed-width");
addressField.getInputs().add(keyAddress); addressField.getInputs().add(keyAddress);
Field utxosField = new Field();
utxosField.setText("UTXOs:");
keyUtxos = new CopyableLabel();
utxosField.getInputs().add(keyUtxos);
Field toAddressField = new Field(); Field toAddressField = new Field();
toAddressField.setText("Sweep to:"); toAddressField.setText("Sweep to:");
toAddress = new ComboBoxTextField(); toAddress = new ComboBoxTextField();
@ -133,7 +150,24 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
stackPane.getChildren().addAll(toWallet, toAddress); stackPane.getChildren().addAll(toWallet, toAddress);
toAddressField.getInputs().add(stackPane); toAddressField.getInputs().add(stackPane);
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField); Field feeRangeField = new Field();
feeRangeField.setText("Fee range:");
feeRange = new FeeRangeSlider();
feeRange.setMaxWidth(320);
feeRangeField.getInputs().add(feeRange);
Field feeRateField = new Field();
feeRateField.setText("Fee rate:");
feeRate = new CopyableLabel();
feeRateField.getInputs().add(feeRate);
feeRange.valueProperty().addListener((observable, oldValue, newValue) -> {
updateFeeRate();
});
feeRange.setFeeRate(AppServices.getDefaultFeeRate());
updateFeeRate();
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField, feeRangeField, feeRateField);
form.getChildren().add(fieldset); form.getChildren().add(fieldset);
dialogPane.setContent(form); dialogPane.setContent(form);
@ -157,6 +191,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
boolean isValidKey = isValidKey(); boolean isValidKey = isValidKey();
createButton.setDisable(!isValidKey || !isValidToAddress()); createButton.setDisable(!isValidKey || !isValidToAddress());
setScriptTypes(isValidKey);
if(isValidKey) { if(isValidKey) {
setFromAddress(); setFromAddress();
} }
@ -188,6 +223,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
setResultConverter(dialogButton -> null); setResultConverter(dialogButton -> null);
dialogPane.setPrefWidth(680); dialogPane.setPrefWidth(680);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
ValidationSupport validationSupport = new ValidationSupport(); ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> { Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
@ -211,6 +251,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private void decryptKey() { private void decryptKey() {
PassphraseDialog passphraseDialog = new PassphraseDialog(); PassphraseDialog passphraseDialog = new PassphraseDialog();
passphraseDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optPassphrase = passphraseDialog.showAndWait(); Optional<String> optPassphrase = passphraseDialog.showAndWait();
if(optPassphrase.isPresent()) { if(optPassphrase.isPresent()) {
try { try {
@ -250,8 +291,19 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
keyAddress.setText(address.toString()); keyAddress.setText(address.toString());
} }
private void setScriptTypes(boolean isValidKey) {
boolean compressed = !isValidKey || getPrivateKey().getKey().isCompressed();
if(compressed && !keyScriptType.getItems().equals(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE))) {
keyScriptType.getItems().addAll(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE).stream().filter(s -> !keyScriptType.getItems().contains(s)).collect(Collectors.toList()));
} else if(!compressed && !keyScriptType.getItems().equals(List.of(ScriptType.P2PKH))) {
keyScriptType.getSelectionModel().select(0);
keyScriptType.getItems().removeIf(scriptType -> scriptType != ScriptType.P2PKH);
}
}
private void scanPrivateKey() { private void scanPrivateKey() {
QRScanDialog qrScanDialog = new QRScanDialog(); QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait(); Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
if(result.isPresent() && result.get().payload != null) { if(result.isPresent() && result.get().payload != null) {
key.setText(result.get().payload); key.setText(result.get().payload);
@ -279,6 +331,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
} }
} }
private void unsealPrivateKey() {
DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList());
deviceUnsealDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<DeviceUnsealDialog.DevicePrivateKey> optPrivateKey = deviceUnsealDialog.showAndWait();
if(optPrivateKey.isPresent()) {
DeviceUnsealDialog.DevicePrivateKey devicePrivateKey = optPrivateKey.get();
key.setText(devicePrivateKey.privateKey().getPrivateKeyEncoded().toBase58());
keyScriptType.setValue(devicePrivateKey.scriptType());
}
}
private void createTransaction() { private void createTransaction() {
try { try {
DumpedPrivateKey privateKey = getPrivateKey(); DumpedPrivateKey privateKey = getPrivateKey();
@ -286,14 +349,34 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
Address fromAddress = scriptType.getAddress(privateKey.getKey()); Address fromAddress = scriptType.getAddress(privateKey.getKey());
Address destAddress = getToAddress(); Address destAddress = getToAddress();
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress); Date since = null;
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
WalletBirthDateDialog addressScanDateDialog = new WalletBirthDateDialog(null, true);
addressScanDateDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<Date> optSince = addressScanDateDialog.showAndWait();
if(optSince.isPresent()) {
since = optSince.get();
} else {
return;
}
}
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress, since);
addressUtxosService.setOnSucceeded(successEvent -> { addressUtxosService.setOnSucceeded(successEvent -> {
createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress); createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress);
}); });
addressUtxosService.setOnFailed(failedEvent -> { addressUtxosService.setOnFailed(failedEvent -> {
Throwable rootCause = Throwables.getRootCause(failedEvent.getSource().getException());
log.error("Error retrieving outputs for address " + fromAddress, failedEvent.getSource().getException()); log.error("Error retrieving outputs for address " + fromAddress, failedEvent.getSource().getException());
AppServices.showErrorDialog("Error retrieving outputs for address", failedEvent.getSource().getException().getMessage()); AppServices.showErrorDialog("Error retrieving outputs for address", rootCause.getMessage());
}); });
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
ServiceProgressDialog serviceProgressDialog = new ServiceProgressDialog("Address Scan", "Scanning address for transactions...", new DialogImage(DialogImage.Type.SPARROW), addressUtxosService);
serviceProgressDialog.initOwner(getDialogPane().getScene().getWindow());
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
}
addressUtxosService.start(); addressUtxosService.start();
} catch(Exception e) { } catch(Exception e) {
log.error("Error creating sweep transaction", e); log.error("Error creating sweep transaction", e);
@ -313,16 +396,27 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript()); TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
noFeeTransaction.addOutput(sweepOutput); noFeeTransaction.addOutput(sweepOutput);
Double feeRate = AppServices.getDefaultFeeRate(); double feeRate = feeRange.getFeeRate();
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate); long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) { if(feeRate == AppServices.getMinimumRelayFeeRate() && feeRate > 0d) {
fee++; fee++;
} }
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE); long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
if(total - fee <= dustThreshold) { if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats)."); feeRate = AppServices.getMinimumRelayFeeRate();
return; fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + (feeRate > 0d ? 1 : 0);
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
} else {
Optional<ButtonType> optType = AppServices.showWarningDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds (" + total + " sats) for a transaction at this fee rate." +
"\n\nContinue with a minimum fee rate transaction?", ButtonType.YES, ButtonType.NO);
if(optType.isPresent() && optType.get() == ButtonType.NO) {
return;
}
}
} }
Transaction transaction = new Transaction(); Transaction transaction = new Transaction();
@ -334,11 +428,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript())); transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
PSBT psbt = new PSBT(transaction); PSBT psbt = new PSBT(transaction);
//Set witness UTXOs on PSBT inputs first - they are all required when hashing for a Taproot signature
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
}
for(int i = 0; i < txOutputs.size(); i++) { for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i); TransactionOutput utxoOutput = txOutputs.get(i);
TransactionInput txInput = transaction.getInputs().get(i); TransactionInput txInput = transaction.getInputs().get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i); PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) { if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript()); psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
@ -370,6 +470,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
return glyph; return glyph;
} }
private void updateFeeRate() {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
feeRate.setText(format.getCurrencyFormat().format(feeRange.getFeeRate()) + " sats/vB");
}
@Subscribe
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
feeRange.updateTrackHighlight();
}
public class PassphraseDialog extends Dialog<String> { public class PassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase; private final CustomPasswordField passphrase;

View file

@ -1,22 +1,28 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
public enum QRDensity { public enum QRDensity {
NORMAL("Normal", 250), NORMAL("Normal", 400, 2000),
LOW("Low", 80); LOW("Low", 80, 1000);
private final String name; private final String name;
private final int maxFragmentLength; private final int maxUrFragmentLength;
private final int maxBbqrFragmentLength;
QRDensity(String name, int maxFragmentLength) { QRDensity(String name, int maxUrFragmentLength, int maxBbqrFragmentLength) {
this.name = name; this.name = name;
this.maxFragmentLength = maxFragmentLength; this.maxUrFragmentLength = maxUrFragmentLength;
this.maxBbqrFragmentLength = maxBbqrFragmentLength;
} }
public String getName() { public String getName() {
return name; return name;
} }
public int getMaxFragmentLength() { public int getMaxUrFragmentLength() {
return maxFragmentLength; return maxUrFragmentLength;
}
public int getMaxBbqrFragmentLength() {
return maxBbqrFragmentLength;
} }
} }

View file

@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.google.zxing.BarcodeFormat; import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageConfig; import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix; import com.google.zxing.common.BitMatrix;
@ -9,10 +10,14 @@ import com.sparrowwallet.hummingbird.LegacyUREncoder;
import com.sparrowwallet.hummingbird.registry.RegistryType; import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException; import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder; import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoding;
import javafx.concurrent.ScheduledService; import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task; import javafx.concurrent.Task;
import javafx.scene.Node; import javafx.scene.Node;
@ -29,21 +34,30 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public class QRDisplayDialog extends Dialog<UR> { public class QRDisplayDialog extends Dialog<ButtonType> {
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class); private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
private static final int MIN_FRAGMENT_LENGTH = 10; private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int ANIMATION_PERIOD_MILLIS = 200; private static final double ANIMATION_PERIOD_MILLIS = 200d;
private static final int QR_WIDTH = 480; private static final int DEFAULT_QR_SIZE = 580;
private static final int QR_HEIGHT = 480; private static final int REDUCED_QR_SIZE = 520;
private static final BBQREncoding DEFAULT_BBQR_ENCODING = BBQREncoding.ZLIB;
private final int qrSize = getQRSize();
private final UR ur; private final UR ur;
private UREncoder encoder; private UREncoder urEncoder;
private final BBQR bbqr;
private BBQREncoder bbqrEncoder;
private boolean useBbqrEncoding;
private final ImageView qrImageView; private final ImageView qrImageView;
@ -58,17 +72,92 @@ public class QRDisplayDialog extends Dialog<UR> {
private static boolean initialDensityChange; private static boolean initialDensityChange;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException { public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), addLegacyEncodingOption); this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, false);
} }
public QRDisplayDialog(UR ur) { public QRDisplayDialog(UR ur) {
this(ur, false); this(ur, null, false, false, false);
} }
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) { public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, boolean selectBbqrButton) {
this.ur = ur; this.ur = ur;
this.addLegacyEncodingOption = addLegacyEncodingOption; this.bbqr = bbqr;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0); this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
if(bbqr != null) {
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
if(selectBbqrButton) {
useBbqrEncoding = true;
}
}
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
qrImageView = new ImageView();
stackPane.getChildren().add(qrImageView);
qrImageView.setOnScroll(scrollEvent -> {
if(animateQRService != null && animateQRService.isRunning() && scrollEvent.getDeltaY() != 0) {
Duration duration = animateQRService.getPeriod();
Duration newDuration = scrollEvent.getDeltaY() > 0 ? duration.multiply(1.1) : duration.multiply(0.9);
if(newDuration.lessThan(Duration.millis(ANIMATION_PERIOD_MILLIS*10)) && newDuration.greaterThan(Duration.millis(ANIMATION_PERIOD_MILLIS/2))) {
animateQRService.setPeriod(newDuration);
}
}
});
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
nextPart();
if(isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
createAnimateQRService();
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().add(cancelButtonType);
if(this.addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
} else {
final ButtonType densityButtonType = new javafx.scene.control.ButtonType("Change Density", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(densityButtonType);
}
if(bbqr != null) {
final ButtonType bbqrButtonType = new javafx.scene.control.ButtonType("Show BBQr", ButtonBar.ButtonData.BACK_PREVIOUS);
dialogPane.getButtonTypes().add(bbqrButtonType);
}
if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(scanButtonType);
}
dialogPane.setPrefWidth(40 + qrSize + 40);
dialogPane.setPrefHeight(40 + qrSize + 85);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton);
}
public QRDisplayDialog(String data) {
this(data, false);
}
public QRDisplayDialog(String data, boolean addScanButton) {
this.ur = null;
this.bbqr = null;
this.urEncoder = null;
this.bbqrEncoder = null;
final DialogPane dialogPane = new QRDisplayDialogPane(); final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane); setDialogPane(dialogPane);
@ -78,54 +167,32 @@ public class QRDisplayDialog extends Dialog<UR> {
qrImageView = new ImageView(); qrImageView = new ImageView();
stackPane.getChildren().add(qrImageView); stackPane.getChildren().add(qrImageView);
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
nextPart();
if(encoder.isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
createAnimateQRService();
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().add(cancelButtonType);
if(addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
} else {
final ButtonType densityButtonType = new javafx.scene.control.ButtonType("Change Density", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(densityButtonType);
}
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
}
public QRDisplayDialog(String data) {
this.ur = null;
this.encoder = null;
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
qrImageView = new ImageView();
stackPane.getChildren().add(qrImageView);
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll()); dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
qrImageView.setImage(getQrCode(data)); qrImageView.setImage(getQrCode(data));
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); if(qrImageView.getImage() == null) {
Label warning = new Label("Message is too long for display as a QR code");
stackPane.getChildren().add(warning);
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType); dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85); if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(scanButtonType);
}
dialogPane.setPrefWidth(40 + qrSize + 40);
dialogPane.setPrefHeight(40 + qrSize + 85);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this); AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null); setResultConverter(dialogButton -> dialogButton);
}
private int getQRSize() {
return AppServices.isReducedWindowHeight() ? REDUCED_QR_SIZE : DEFAULT_QR_SIZE;
} }
private void createAnimateQRService() { private void createAnimateQRService() {
@ -137,9 +204,22 @@ public class QRDisplayDialog extends Dialog<UR> {
}); });
} }
private boolean isSinglePart() {
if(useBbqrEncoding) {
return bbqrEncoder.isSinglePart();
} else if(!useLegacyEncoding) {
return urEncoder.isSinglePart();
} else {
return legacyParts.length == 1;
}
}
private void nextPart() { private void nextPart() {
if(!useLegacyEncoding) { if(useBbqrEncoding) {
String fragment = encoder.nextPart(); String fragment = bbqrEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else if(!useLegacyEncoding) {
String fragment = urEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT); currentPart = fragment.toUpperCase(Locale.ROOT);
} else { } else {
currentPart = legacyParts[legacyPartIndex]; currentPart = legacyParts[legacyPartIndex];
@ -153,7 +233,7 @@ public class QRDisplayDialog extends Dialog<UR> {
protected Image getQrCode(String fragment) { protected Image getQrCode(String fragment) {
try { try {
QRCodeWriter qrCodeWriter = new QRCodeWriter(); QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT); BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, qrSize, qrSize, Map.of(EncodeHintType.MARGIN, "2"));
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig()); MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
@ -175,37 +255,27 @@ public class QRDisplayDialog extends Dialog<UR> {
this.legacyParts = legacyEncoder.encode(); this.legacyParts = legacyEncoder.encode();
this.useLegacyEncoding = true; this.useLegacyEncoding = true;
if(legacyParts.length == 1) { restartAnimation();
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
} catch(UR.InvalidTypeException e) { } catch(UR.InvalidTypeException e) {
//Can't happen //Can't happen
} }
} else { } else {
this.useLegacyEncoding = false; this.useLegacyEncoding = false;
restartAnimation();
}
}
if(encoder.isSinglePart()) { public boolean isUseBbqrEncoding() {
if(animateQRService != null) { return useBbqrEncoding;
animateQRService.cancel(); }
}
qrImageView.setImage(getQrCode(currentPart)); private void setUseBbqrEncoding(boolean useBbqrEncoding) {
} else if(animateQRService == null) { if(useBbqrEncoding) {
createAnimateQRService(); this.useBbqrEncoding = true;
} else if(!animateQRService.isRunning()) { restartAnimation();
animateQRService.reset(); } else {
animateQRService.start(); this.useBbqrEncoding = false;
} restartAnimation();
} }
} }
@ -214,12 +284,28 @@ public class QRDisplayDialog extends Dialog<UR> {
animateQRService.cancel(); animateQRService.cancel();
} }
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0); if(bbqr != null) {
nextPart(); this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
if(encoder.isSinglePart()) { }
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
restartAnimation();
}
private void restartAnimation() {
if(isSinglePart()) {
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart)); qrImageView.setImage(getQrCode(currentPart));
} else { } else if(animateQRService == null) {
createAnimateQRService(); createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
} }
} }
@ -264,7 +350,7 @@ public class QRDisplayDialog extends Dialog<UR> {
final ButtonBar.ButtonData buttonData = buttonType.getButtonData(); final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(density, buttonData); ButtonBar.setButtonData(density, buttonData);
density.setOnAction(event -> { density.setOnAction(event -> {
if(!initialDensityChange && !encoder.isSinglePart()) { if(!initialDensityChange && !isSinglePart()) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES); Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) { if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
initialDensityChange = true; initialDensityChange = true;
@ -280,6 +366,25 @@ public class QRDisplayDialog extends Dialog<UR> {
return density; return density;
} }
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OK_DONE) {
Button scanButton = (Button)super.createButton(buttonType);
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
return scanButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.BACK_PREVIOUS) {
ToggleButton bbqr = new ToggleButton(buttonType.getText());
bbqr.setGraphicTextGap(5);
bbqr.setGraphic(getGlyph(FontAwesome5.Glyph.QRCODE));
bbqr.setSelected(useBbqrEncoding);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(bbqr, buttonData);
bbqr.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseBbqrEncoding(newValue);
});
return bbqr;
} }
return super.createButton(buttonType); return super.createButton(buttonType);

View file

@ -1,10 +1,7 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.*; import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.P2PKHAddress; import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.address.P2SHAddress; import com.sparrowwallet.drongo.address.P2SHAddress;
@ -16,6 +13,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException; import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed; import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR; import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
@ -24,9 +22,15 @@ import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.hummingbird.ResultType; import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR; import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.URDecoder; import com.sparrowwallet.hummingbird.URDecoder;
import com.sparrowwallet.hummingbird.registry.pathcomponent.IndexPathComponent;
import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WebcamResolutionChangedEvent;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQRException;
import com.sparrowwallet.sparrow.wallet.KeystoreController;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.DoubleProperty; import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
@ -34,14 +38,16 @@ import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener; import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets; import javafx.geometry.Insets;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.layout.*; import javafx.scene.layout.*;
import javafx.util.Duration; import javafx.util.Duration;
import javafx.util.StringConverter; import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders; import org.controlsfx.tools.Borders;
import org.openpnp.capture.CaptureDevice;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -53,14 +59,16 @@ import java.util.*;
import java.util.List; import java.util.List;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
public class QRScanDialog extends Dialog<QRScanDialog.Result> { public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class); private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
private final URDecoder decoder; private final URDecoder urDecoder;
private final LegacyURDecoder legacyDecoder; private final LegacyURDecoder legacyUrDecoder;
private final BBQRDecoder bbqrDecoder;
private final WebcamService webcamService; private final WebcamService webcamService;
private List<String> parts; private List<String> parts;
@ -69,106 +77,142 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)"); private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
private static final int SCAN_PERIOD_MILLIS = 100; private static final int SCAN_PERIOD_MILLIS = 100;
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA); private final ObjectProperty<CaptureDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.HD);
private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0); private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0);
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>(); private final ObservableList<CaptureDevice> foundDevices = FXCollections.observableList(new ArrayList<>());
private final ObservableList<WebcamResolution> availableResolutions = FXCollections.observableList(new ArrayList<>());
private boolean postOpenUpdate;
public QRScanDialog() { public QRScanDialog() {
this.decoder = new URDecoder(); this.urDecoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder(); this.legacyUrDecoder = new LegacyURDecoder();
this.bbqrDecoder = new BBQRDecoder();
if(Config.get().isHdCapture()) { if(Config.get().getWebcamResolution() != null) {
webcamResolutionProperty.set(WebcamResolution.HD); webcamResolutionProperty.set(Config.get().getWebcamResolution());
} }
this.webcamService = new WebcamService(webcamResolutionProperty.get(), null, new QRScanListener(), new ScanDelayCalculator()); this.webcamService = new WebcamService(webcamResolutionProperty.get(), null);
webcamService.setPeriod(Duration.millis(SCAN_PERIOD_MILLIS)); webcamService.setPeriod(Duration.millis(SCAN_PERIOD_MILLIS));
webcamService.setRestartOnFailure(false); webcamService.setRestartOnFailure(false);
WebcamView webcamView = new WebcamView(webcamService);
final DialogPane dialogPane = new QRScanDialogPane(); final DialogPane dialogPane = new QRScanDialogPane();
setDialogPane(dialogPane); setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow()); AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane(); WebcamView webcamView = new WebcamView(webcamService, Config.get().isMirrorCapture());
stackPane.getChildren().add(webcamView.getView());
Node wrappedView = Borders.wrap(stackPane).lineBorder().buildAll();
ProgressBar progressBar = new ProgressBar(); ProgressBar progressBar = new ProgressBar();
progressBar.setMinHeight(20); progressBar.setMinHeight(20);
progressBar.setPadding(new Insets(0, 10, 0, 10)); progressBar.setPadding(new Insets(0, 10, 0, 10));
progressBar.setPrefWidth(Integer.MAX_VALUE); progressBar.setPrefWidth(Integer.MAX_VALUE);
progressBar.progressProperty().bind(percentComplete); progressBar.progressProperty().bind(percentComplete);
webcamService.openingProperty().addListener((observable, oldValue, newValue) -> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(newValue ? 0.0 : -1.0));
}
Platform.runLater(() -> {
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
for(WebcamDevice device : WebcamScanDriver.getFoundDevices()) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
webcamDeviceProperty.set(device);
}
}
}
});
});
VBox vBox = new VBox(20); VBox vBox = new VBox(20);
StackPane stackPane = new StackPane();
stackPane.getChildren().add(webcamView.getView());
Node wrappedView = Borders.wrap(stackPane).lineBorder().buildAll();
vBox.getChildren().addAll(wrappedView, progressBar); vBox.getChildren().addAll(wrappedView, progressBar);
dialogPane.setContent(vBox); dialogPane.setContent(vBox);
webcamService.openingProperty().addListener((_, _, opening) -> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0));
}
});
webcamService.openedProperty().addListener((_, _, opened) -> {
if(opened) {
Platform.runLater(() -> {
try {
postOpenUpdate = true;
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getAvailableDevices());
newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !webcamService.getDevices().contains(device));
if(webcamService.getDevice() != null) {
for(CaptureDevice device : foundDevices) {
if(device.equals(webcamService.getDevice())) {
webcamDeviceProperty.set(device);
}
}
}
updateList(availableResolutions, webcamService.getResolutions());
webcamResolutionProperty.set(webcamService.getResolution());
} finally {
postOpenUpdate = false;
}
});
} else if(webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> {
if(!webcamService.isRunning()) {
webcamService.reset();
webcamService.start();
}
});
}
});
webcamService.resultProperty().addListener(new QRResultListener()); webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> { webcamService.setOnFailed(failedEvent -> {
Throwable exception = failedEvent.getSource().getException(); Throwable exception = Throwables.getRootCause(failedEvent.getSource().getException());
Platform.runLater(() -> setResult(new Result(exception)));
Throwable nested = exception;
while(nested.getCause() != null) {
nested = nested.getCause();
}
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS &&
nested.getMessage().startsWith("Library 'OpenIMAJGrabber' was not loaded successfully from file")) {
exception = new WebcamDependencyException("Your system is missing a dependency required for the webcam. Follow the link below for more details.\n\n[https://sparrowwallet.com/docs/faq.html#your-system-is-missing-a-dependency-for-the-webcam]", exception);
} else if(nested.getMessage().startsWith("Cannot start native grabber") && Config.get().getWebcamDevice() != null) {
exception = new WebcamOpenException("Cannot open configured webcam " + Config.get().getWebcamDevice() + ", reverting to the default webcam");
Config.get().setWebcamDevice(null);
}
final Throwable result = exception;
Platform.runLater(() -> setResult(new Result(result)));
}); });
webcamService.start(); webcamService.start();
webcamResolutionProperty.addListener((observable, oldValue, newResolution) -> {
webcamResolutionProperty.addListener((_, oldResolution, newResolution) -> {
if(newResolution != null) { if(newResolution != null) {
setHeight(newResolution == WebcamResolution.HD ? (getHeight() - 100) : (getHeight() + 100)); if(newResolution.isStandardAspect() && oldResolution.isWidescreenAspect()) {
setWidth(getWidth());
setHeight(getHeight() + 100);
dialogPane.setMaxHeight(dialogPane.getPrefHeight() + 100);
dialogPane.setPrefHeight(dialogPane.getMaxHeight());
dialogPane.setMinHeight(dialogPane.getMaxHeight());
} else if(newResolution.isWidescreenAspect() && oldResolution.isStandardAspect()) {
setWidth(getWidth());
setHeight(getHeight() - 100);
dialogPane.setMaxHeight(dialogPane.getPrefHeight() - 100);
dialogPane.setPrefHeight(dialogPane.getMaxHeight());
dialogPane.setMinHeight(dialogPane.getMaxHeight());
}
EventManager.get().post(new WebcamResolutionChangedEvent(newResolution));
}
if(newResolution == null || !postOpenUpdate) {
webcamService.cancel();
} }
webcamService.cancel();
}); });
webcamDeviceProperty.addListener((observable, oldValue, newValue) -> { webcamDeviceProperty.addListener((_, _, newValue) -> {
Config.get().setWebcamDevice(newValue.getName()); Config.get().setWebcamDevice(newValue.getName());
Config.get().setWebcamDeviceId(newValue.getUniqueId());
if(!Objects.equals(webcamService.getDevice(), newValue)) { if(!Objects.equals(webcamService.getDevice(), newValue)) {
webcamService.cancel(); webcamService.cancel();
} }
}); });
setOnCloseRequest(event -> { setOnCloseRequest(_ -> {
boolean isHdCapture = (webcamResolutionProperty.get() == WebcamResolution.HD); if(webcamResolutionProperty.get() != null) {
if(Config.get().isHdCapture() != isHdCapture) { Config.get().setWebcamResolution(webcamResolutionProperty.get());
Config.get().setHdCapture(isHdCapture);
} }
Platform.runLater(() -> webcamResolutionProperty.set(null)); Platform.runLater(() -> {
webcamResolutionProperty.set(null);
webcamService.close();
});
}); });
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT); final ButtonType deviceButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.LEFT);
final ButtonType camButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.HELP_2); final ButtonType resolutionButtonType = new javafx.scene.control.ButtonType("Resolution", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType); dialogPane.getButtonTypes().addAll(deviceButtonType, resolutionButtonType, cancelButtonType);
dialogPane.setPrefWidth(646); dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590); dialogPane.setPrefHeight(webcamResolutionProperty.get().isWidescreenAspect() ? 490 : 590);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this); AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null); setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
@ -187,30 +231,44 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) { if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) {
if(LegacyURDecoder.isLegacyURFragment(qrtext)) { if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext); legacyUrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete())); Platform.runLater(() -> percentComplete.setValue(legacyUrDecoder.getPercentComplete()));
if(legacyDecoder.isComplete()) { if(legacyUrDecoder.isComplete()) {
try { try {
UR ur = legacyDecoder.decode(); UR ur = legacyUrDecoder.decode();
result = extractResultFromUR(ur); result = extractResultFromUR(ur);
} catch(Exception e) { } catch(Exception e) {
result = new Result(new URException(e.getMessage())); result = new Result(new URException(e.getMessage()));
} }
} }
} else { } else {
decoder.receivePart(qrtext); urDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0)); Platform.runLater(() -> percentComplete.setValue(urDecoder.getProcessedPartsCount() > 0 ? urDecoder.getEstimatedPercentComplete() : 0));
if(decoder.getResult() != null) { if(urDecoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult(); URDecoder.Result urResult = urDecoder.getResult();
if(urResult.type == ResultType.SUCCESS) { if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur); result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result));
} else { } else {
result = new Result(new URException(urResult.error)); result = new Result(new URException(urResult.error));
} }
} }
} }
} else if(BBQRDecoder.isBBQRFragment(qrtext)) {
bbqrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(bbqrDecoder.getPercentComplete()));
if(bbqrDecoder.getResult() != null) {
BBQRDecoder.Result bbqrResult = bbqrDecoder.getResult();
if(bbqrResult.getResultType() == BBQRDecoder.ResultType.SUCCESS) {
result = extractResultFromBBQR(bbqrResult);
Platform.runLater(() -> setResult(result));
} else {
result = new Result(new BBQRException(bbqrResult.getError()));
}
}
} else if(partMatcher.matches()) { } else if(partMatcher.matches()) {
int m = Integer.parseInt(partMatcher.group(1)); int m = Integer.parseInt(partMatcher.group(1));
int n = Integer.parseInt(partMatcher.group(2)); int n = Integer.parseInt(partMatcher.group(2));
@ -259,7 +317,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
DeterministicSeed seed; DeterministicSeed seed;
try { try {
extendedKey = ExtendedKey.fromDescriptor(qrtext); extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey); result = new Result(extendedKey, null);
return; return;
} catch(Exception e) { } catch(Exception e) {
//Ignore, not a valid xpub //Ignore, not a valid xpub
@ -350,6 +408,17 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Ignore, not parseable as a CompactSeedQR //Ignore, not parseable as a CompactSeedQR
} }
try {
List<String> words = Arrays.asList(qrtext.split(" "));
if(words.size() == 12 || words.size() == 15 || words.size() == 18 || words.size() == 21 || words.size() == 24) {
Bip39MnemonicCode.INSTANCE.check(words);
result = new Result(new DeterministicSeed(words, null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39));
return;
}
} catch(Exception e) {
//Ignore, not parseable as BIP39 seed words
}
result = new Result(qrtext); result = new Result(qrtext);
} }
} }
@ -408,7 +477,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) { } else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry(); CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey); ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
return new Result(extendedKey); return new Result(extendedKey, cryptoHDKey.getName());
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) { } else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry(); CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput); OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
@ -425,6 +494,39 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
CryptoBip39 cryptoBip39 = (CryptoBip39)ur.decodeFromRegistry(); CryptoBip39 cryptoBip39 = (CryptoBip39)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoBip39); DeterministicSeed seed = getSeed(cryptoBip39);
return new Result(seed); return new Result(seed);
} else if(urRegistryType.equals(RegistryType.PSBT)) {
URPSBT urPSBT = (URPSBT)ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(urPSBT.getPsbt(), false);
return new Result(psbt);
} catch(Exception e) {
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
return new Result(new URException("Error parsing PSBT from UR type " + urRegistryType, e));
}
} else if(urRegistryType.equals(RegistryType.ADDRESS)) {
URAddress urAddress = (URAddress)ur.decodeFromRegistry();
Address address = getAddress(urAddress);
if(address != null) {
return new Result(BitcoinURI.fromAddress(address));
} else {
return new Result(new URException("Unknown " + urRegistryType + " type of " + urAddress.getType()));
}
} else if(urRegistryType.equals(RegistryType.HDKEY)) {
URHDKey urHDKey = (URHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(urHDKey);
return new Result(extendedKey, urHDKey.getName());
} else if(urRegistryType.equals(RegistryType.OUTPUT_DESCRIPTOR)) {
UROutputDescriptor urOutputDescriptor = (UROutputDescriptor)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(urOutputDescriptor);
return new Result(outputDescriptor);
} else if(urRegistryType.equals(RegistryType.ACCOUNT_DESCRIPTOR)) {
URAccountDescriptor urAccountDescriptor = (URAccountDescriptor)ur.decodeFromRegistry();
List<Wallet> wallets = getWallets(urAccountDescriptor);
return new Result(wallets);
} else if(urRegistryType.equals(RegistryType.SEED)) {
URSeed urSeed = (URSeed)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(urSeed);
return new Result(seed);
} else { } else {
log.error("Unsupported UR type " + urRegistryType); log.error("Unsupported UR type " + urRegistryType);
return new Result(new URException("UR type " + urRegistryType + " is not supported")); return new Result(new URException("UR type " + urRegistryType + " is not supported"));
@ -466,7 +568,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoHDKey.getOrigin() != null) { if(cryptoHDKey.getOrigin() != null) {
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) { if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1); PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened()); if(lastComponent instanceof IndexPathComponent indexPathComponent) {
lastChild = new ChildNumber(indexPathComponent.getIndex(), indexPathComponent.isHardened());
}
depth = cryptoHDKey.getOrigin().getComponents().size(); depth = cryptoHDKey.getOrigin().getComponents().size();
} }
if(cryptoHDKey.getParentFingerprint() != null) { if(cryptoHDKey.getParentFingerprint() != null) {
@ -484,18 +588,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoOutput.getMultiKey() != null) { if(cryptoOutput.getMultiKey() != null) {
MultiKey multiKey = cryptoOutput.getMultiKey(); MultiKey multiKey = cryptoOutput.getMultiKey();
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>(); Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
Map<ExtendedKey, String> extendedPublicKeyLabels = new LinkedHashMap<>();
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) { for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey); ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin()); KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
extendedPublicKeys.put(extendedKey, keyDerivation); extendedPublicKeys.put(extendedKey, keyDerivation);
if(cryptoHDKey.getName() != null) {
extendedPublicKeyLabels.put(extendedKey, cryptoHDKey.getName());
}
} }
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys); return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys, new LinkedHashMap<>(), extendedPublicKeyLabels);
} else if(cryptoOutput.getEcKey() != null) { } else if(cryptoOutput.getEcKey() != null) {
throw new IllegalArgumentException("EC keys are currently unsupported"); throw new IllegalArgumentException("EC keys are currently unsupported");
} else if(cryptoOutput.getHdKey() != null) { } else if(cryptoOutput.getHdKey() != null) {
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey()); ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin()); KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
return new OutputDescriptor(scriptType, extendedKey, keyDerivation); return new OutputDescriptor(scriptType, extendedKey, keyDerivation, cryptoOutput.getHdKey().getName());
} }
throw new IllegalStateException("CryptoOutput did not contain sufficient information"); throw new IllegalStateException("CryptoOutput did not contain sufficient information");
@ -542,7 +650,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) { private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
if(cryptoKeypath != null) { if(cryptoKeypath != null) {
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), cryptoKeypath.getPath()); if(!cryptoKeypath.getComponents().stream().allMatch(pathComponent -> pathComponent instanceof IndexPathComponent)) {
throw new IllegalArgumentException("Only indexed derivation path components are supported");
}
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint());
return new KeyDerivation(fingerprint, KeyDerivation.writePath(path));
} }
return null; return null;
@ -555,74 +670,81 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private DeterministicSeed getSeed(CryptoBip39 cryptoBip39) { private DeterministicSeed getSeed(CryptoBip39 cryptoBip39) {
return new DeterministicSeed(cryptoBip39.getWords(), null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39); return new DeterministicSeed(cryptoBip39.getWords(), null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
} }
}
private class QRScanListener implements WebcamListener { private OutputDescriptor getOutputDescriptor(UROutputDescriptor urOutputDescriptor) {
@Override String source = urOutputDescriptor.getSource();
public void webcamOpen(WebcamEvent webcamEvent) { List<RegistryItem> keys = urOutputDescriptor.getKeys();
Map<ExtendedKey, String> mapExtendedPublicKeyLabels = new LinkedHashMap<>();
} for(int i = 0; i < keys.size(); i++) {
RegistryItem key = keys.get(i);
@Override if(key instanceof URHDKey urhdKey) {
public void webcamClosed(WebcamEvent webcamEvent) { ExtendedKey extendedKey = getExtendedKey(urhdKey);
if(webcamResolutionProperty.get() != null) { KeyDerivation keyDerivation = getKeyDerivation(urhdKey.getOrigin());
webcamService.setResolution(webcamResolutionProperty.get()); source = source.replaceAll("@" + i, OutputDescriptor.writeKey(extendedKey, keyDerivation, null, true, true));
webcamService.setDevice(webcamDeviceProperty.get()); if(urhdKey.getName() != null) {
Platform.runLater(() -> { mapExtendedPublicKeyLabels.put(extendedKey, urhdKey.getName());
if(!webcamService.isRunning()) {
webcamService.reset();
webcamService.start();
} }
}); } else {
throw new IllegalArgumentException("Only extended HD keys are supported in output descriptors");
}
} }
return OutputDescriptor.getOutputDescriptor(source, mapExtendedPublicKeyLabels);
} }
@Override private List<Wallet> getWallets(URAccountDescriptor urAccountDescriptor) {
public void webcamDisposed(WebcamEvent webcamEvent) { List<Wallet> wallets = new ArrayList<>();
String masterFingerprint = Utils.bytesToHex(urAccountDescriptor.getMasterFingerprint());
for(UROutputDescriptor urOutputDescriptor : urAccountDescriptor.getOutputDescriptors()) {
OutputDescriptor outputDescriptor = getOutputDescriptor(urOutputDescriptor);
Wallet wallet = outputDescriptor.toKeystoreWallet(masterFingerprint);
wallets.add(wallet);
}
return wallets;
} }
@Override private Result extractResultFromBBQR(BBQRDecoder.Result result) {
public void webcamImageObtained(WebcamEvent webcamEvent) { if(result.getPsbt() != null) {
return new Result(result.getPsbt());
} else if(result.getTransaction() != null) {
return new Result(result.getTransaction());
} else if(result.toString() != null) {
return new Result(result.toString());
} else {
log.error("Unsupported BBQR type " + result.getBbqrType());
return new Result(new URException("BBQR type " + result.getBbqrType() + " is not supported"));
}
} }
} }
private class QRScanDialogPane extends DialogPane { private class QRScanDialogPane extends DialogPane {
@Override @Override
protected Node createButton(ButtonType buttonType) { protected Node createButton(ButtonType buttonType) {
Node button = null; Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) { if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton hd = new ToggleButton(buttonType.getText()); ComboBox<CaptureDevice> devicesCombo = new ComboBox<>(foundDevices);
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
hd.setGraphicTextGap(5);
setHdGraphic(hd, hd.isSelected());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(hd, buttonData);
hd.selectedProperty().addListener((observable, oldValue, newValue) -> {
webcamResolutionProperty.set(newValue ? WebcamResolution.HD : WebcamResolution.VGA);
setHdGraphic(hd, newValue);
});
button = hd;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<WebcamDevice> devicesCombo = new ComboBox<>(WebcamScanDriver.getFoundDevices());
devicesCombo.setConverter(new StringConverter<>() { devicesCombo.setConverter(new StringConverter<>() {
@Override @Override
public String toString(WebcamDevice device) { public String toString(CaptureDevice device) {
return device instanceof WebcamScanDevice ? ((WebcamScanDevice)device).getDeviceName() : "Default Camera"; return device != null && device.getName() != null ? device.getName().replaceAll(" \\(.*\\)", "") : "Default Camera";
} }
@Override @Override
public WebcamDevice fromString(String string) { public CaptureDevice fromString(String string) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
}); });
devicesCombo.valueProperty().bindBidirectional(webcamDeviceProperty); devicesCombo.valueProperty().bindBidirectional(webcamDeviceProperty);
ButtonBar.setButtonData(devicesCombo, ButtonBar.ButtonData.LEFT); final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(devicesCombo, buttonData);
button = devicesCombo; button = devicesCombo;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<WebcamResolution> resolutionsCombo = new ComboBox<>(availableResolutions);
resolutionsCombo.valueProperty().bindBidirectional(webcamResolutionProperty);
ButtonBar.setButtonData(resolutionsCombo, ButtonBar.ButtonData.LEFT);
button = resolutionsCombo;
} else { } else {
button = super.createButton(buttonType); button = super.createButton(buttonType);
} }
@ -635,19 +757,39 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
button.disableProperty().bind(webcamService.openingProperty()); button.disableProperty().bind(webcamService.openingProperty());
return button; return button;
} }
}
private void setHdGraphic(ToggleButton hd, boolean isHd) { public static <T extends Comparable<T>> void updateList(List<T> targetList, Collection<T> sourceList) {
if(isHd) { List<T> sortedSource = new ArrayList<>(sourceList);
hd.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE)); Collections.sort(sortedSource);
ListIterator<T> targetIter = targetList.listIterator();
int sourceIndex = 0;
while (sourceIndex < sortedSource.size() && targetIter.hasNext()) {
T sourceItem = sortedSource.get(sourceIndex);
T targetItem = targetIter.next();
int comparison = sourceItem.compareTo(targetItem);
if (comparison < 0) {
targetIter.previous(); // Back up to insert before
targetIter.add(sourceItem);
sourceIndex++;
} else if (comparison > 0) {
targetIter.remove();
} else { } else {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.BAN)); sourceIndex++;
} }
} }
private Glyph getGlyph(FontAwesome5.Glyph glyphName) { while (sourceIndex < sortedSource.size()) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName); targetIter.add(sortedSource.get(sourceIndex));
glyph.setFontSize(11); sourceIndex++;
return glyph; }
while (targetIter.hasNext()) {
targetIter.next();
targetIter.remove();
} }
} }
@ -656,6 +798,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final PSBT psbt; public final PSBT psbt;
public final BitcoinURI uri; public final BitcoinURI uri;
public final ExtendedKey extendedKey; public final ExtendedKey extendedKey;
public final String extendedKeyName;
public final OutputDescriptor outputDescriptor; public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets; public final List<Wallet> wallets;
public final DeterministicSeed seed; public final DeterministicSeed seed;
@ -667,6 +810,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -679,6 +823,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = psbt; this.psbt = psbt;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -691,6 +836,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = uri; this.uri = uri;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -703,6 +849,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = BitcoinURI.fromAddress(address); this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -710,11 +857,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.exception = null; this.exception = null;
} }
public Result(ExtendedKey extendedKey) { public Result(ExtendedKey extendedKey, String name) {
this.transaction = null; this.transaction = null;
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = extendedKey; this.extendedKey = extendedKey;
this.extendedKeyName = name;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -727,6 +875,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = outputDescriptor; this.outputDescriptor = outputDescriptor;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -739,6 +888,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = wallets; this.wallets = wallets;
this.seed = null; this.seed = null;
@ -751,6 +901,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = seed; this.seed = seed;
@ -763,6 +914,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -775,6 +927,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null; this.psbt = null;
this.uri = null; this.uri = null;
this.extendedKey = null; this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null; this.outputDescriptor = null;
this.wallets = null; this.wallets = null;
this.seed = null; this.seed = null;
@ -854,10 +1007,4 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
super(message, cause); super(message, cause);
} }
} }
public static class ScanDelayCalculator implements WebcamUpdater.DelayCalculator {
public long calculateDelay(long snapshotDuration, double deviceFps) {
return Math.max(SCAN_PERIOD_MILLIS - snapshotDuration, 0L);
}
}
} }

View file

@ -0,0 +1,178 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.BlockSummary;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import io.reactivex.Observable;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import javafx.animation.TranslateTransition;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.sparrowwallet.sparrow.AppServices.TARGET_BLOCKS_RANGE;
import static com.sparrowwallet.sparrow.control.BlockCube.CUBE_SIZE;
public class RecentBlocksView extends Pane {
private static final double CUBE_SPACING = 100;
private static final double ANIMATION_DURATION_MILLIS = 1000;
private static final double SEPARATOR_X = 74;
private final CompositeDisposable disposables = new CompositeDisposable();
private final ObjectProperty<List<BlockCube>> cubesProperty = new SimpleObjectProperty<>(new ArrayList<>());
private final Tooltip tooltip = new Tooltip();
public RecentBlocksView() {
cubesProperty.addListener((_, _, newValue) -> {
if(newValue != null && newValue.size() == 3) {
drawView();
}
});
Rectangle clip = new Rectangle(-20, -40, CUBE_SPACING * 3 - 20, 100);
setClip(clip);
Observable<Long> intervalObservable = Observable.interval(1, TimeUnit.MINUTES);
disposables.add(intervalObservable.observeOn(JavaFxScheduler.platform()).subscribe(_ -> {
for(BlockCube cube : getCubes()) {
cube.setElapsed(BlockCube.getElapsed(cube.getTimestamp()));
}
}));
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
updateFeeRatesSource(feeRatesSource);
Tooltip.install(this, tooltip);
}
public void updateFeeRatesSource(FeeRatesSource feeRatesSource) {
tooltip.setText("Fee rate estimate from " + feeRatesSource.getDescription());
if(getCubes() != null && !getCubes().isEmpty()) {
getCubes().getFirst().setFeeRatesSource(feeRatesSource);
}
}
public void drawView() {
createSeparator();
for(int i = 0; i < 3; i++) {
BlockCube cube = getCubes().get(i);
cube.setTranslateX(i * CUBE_SPACING);
getChildren().add(cube);
}
}
private void createSeparator() {
Line separator = new Line(SEPARATOR_X, -9, SEPARATOR_X, CUBE_SIZE);
separator.getStyleClass().add("blocks-separator");
separator.getStrokeDashArray().addAll(5.0, 5.0); // Create dotted line pattern
separator.setStrokeWidth(1.0);
getChildren().add(separator);
}
public void update(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
List<BlockCube> cubes = new ArrayList<>();
cubes.add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
cubes.addAll(latestBlocks.stream().map(BlockCube::fromBlockSummary).limit(2).toList());
setCubes(cubes);
} else {
int knownTip = getCubes().stream().mapToInt(BlockCube::getHeight).max().orElse(0);
int latestTip = latestBlocks.stream().mapToInt(BlockSummary::getHeight).max().orElse(0);
if(latestTip > knownTip) {
addNewBlock(latestBlocks, currentFeeRate);
} else {
for(int i = 1; i < getCubes().size() && i <= latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i - 1);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(-1.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
updateFeeRate(currentFeeRate);
}
}
}
private void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
return;
}
for(int i = 0; i < getCubes().size() && i < latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(-1.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
}
public void add(BlockCube newCube) {
newCube.setTranslateX(-CUBE_SPACING);
getChildren().add(newCube);
getCubes().getFirst().setConfirmed(true);
getCubes().addFirst(newCube);
animateCubes();
if(getCubes().size() > 4) {
BlockCube lastCube = getCubes().getLast();
getChildren().remove(lastCube);
getCubes().remove(lastCube);
}
}
public void updateFeeRate(Map<Integer, Double> targetBlockFeeRates) {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
if(targetBlockFeeRates.get(defaultTarget) != null) {
Double defaultRate = targetBlockFeeRates.get(defaultTarget);
updateFeeRate(defaultRate);
}
}
public void updateFeeRate(Double currentFeeRate) {
if(!getCubes().isEmpty()) {
BlockCube firstCube = getCubes().getFirst();
firstCube.setMedianFee(currentFeeRate);
}
}
private void animateCubes() {
for(int i = 0; i < getCubes().size(); i++) {
BlockCube cube = getCubes().get(i);
TranslateTransition transition = new TranslateTransition(Duration.millis(ANIMATION_DURATION_MILLIS), cube);
transition.setToX(i * CUBE_SPACING);
transition.play();
}
}
public List<BlockCube> getCubes() {
return cubesProperty.get();
}
public ObjectProperty<List<BlockCube>> cubesProperty() {
return cubesProperty;
}
public void setCubes(List<BlockCube> cubes) {
this.cubesProperty.set(cubes);
}
}

View file

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script; import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk; import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.drongo.protocol.ScriptOpCodes;
import javafx.geometry.Pos; import javafx.geometry.Pos;
import org.controlsfx.control.decoration.Decorator; import org.controlsfx.control.decoration.Decorator;
import org.controlsfx.control.decoration.GraphicDecoration; import org.controlsfx.control.decoration.GraphicDecoration;
@ -53,11 +54,19 @@ public class ScriptArea extends CodeArea {
for (int i = 0; i < script.getChunks().size(); i++) { for (int i = 0; i < script.getChunks().size(); i++) {
ScriptChunk chunk = script.getChunks().get(i); ScriptChunk chunk = script.getChunks().get(i);
if(chunk.isOpCode()) { if(chunk.isOpCode()) {
append(chunk.toString(), "script-opcode"); if(chunk.getOpcode() == ScriptOpCodes.OP_0 && witnessScript != null) {
} else if(chunk.isSignature()) { append("<empty>", "script-other");
append("<signature" + signatureCount++ + ">", "script-signature"); } else {
append(chunk.toString(), "script-opcode");
}
} else if(chunk.isPubKey()) { } else if(chunk.isPubKey()) {
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey"); append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
} else if(chunk.isSignature()) {
append("<signature" + signatureCount++ + ">", "script-signature");
} else if(chunk.isTaprootControlBlock()) {
append("<controlblock>", "script-controlblock");
} else if(chunk.isString()) {
append(chunk.toString(), "script-other");
} else if(chunk.isScript()) { } else if(chunk.isScript()) {
Script nestedScript = chunk.getScript(); Script nestedScript = chunk.getScript();
if (nestedScript.equals(redeemScript)) { if (nestedScript.equals(redeemScript)) {

View file

@ -2,25 +2,22 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script; import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk; import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.sparrow.BaseController;
import javafx.geometry.Point2D; import javafx.geometry.Point2D;
import javafx.scene.control.ContextMenu; import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem; import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard; import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent; import javafx.scene.input.ClipboardContent;
import javafx.scene.input.ContextMenuEvent; import javafx.scene.input.ContextMenuEvent;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.model.TwoDimensional;
import java.util.OptionalInt; import java.util.OptionalInt;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
public class ScriptContextMenu extends ContextMenu { public class ScriptContextMenu extends ContextMenu {
private Script script; private Script script;
private MenuItem copyvalue; private MenuItem copyvalue;
private ScriptChunk hoverChunk; private ScriptChunk hoverChunk;
public ScriptContextMenu(CodeArea area, Script script) public ScriptContextMenu(ScriptArea area, Script script)
{ {
this.script = script; this.script = script;
@ -40,12 +37,9 @@ public class ScriptContextMenu extends ContextMenu {
Point2D point = area.screenToLocal(event.getScreenX(), event.getScreenY()); Point2D point = area.screenToLocal(event.getScreenX(), event.getScreenY());
OptionalInt characterIndex = area.hit(point.getX(), point.getY()).getCharacterIndex(); OptionalInt characterIndex = area.hit(point.getX(), point.getY()).getCharacterIndex();
if(characterIndex.isPresent()) { if(characterIndex.isPresent()) {
TwoDimensional.Position position = area.getParagraph(0).getStyleSpans().offsetToPosition(characterIndex.getAsInt(), Backward); ScriptChunk chunk = BaseController.getScriptChunk(area, characterIndex.getAsInt());
if(position.getMajor() % 2 == 0) { if(chunk != null) {
ScriptChunk chunk = script.getChunks().get(position.getMajor() / 2); this.hoverChunk = chunk;
if(!chunk.isOpCode()) {
this.hoverChunk = chunk;
}
} }
} }
copyvalue.setDisable(hoverChunk == null); copyvalue.setDisable(hoverChunk == null);

View file

@ -1,16 +1,28 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.csvreader.CsvWriter;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.wallet.TableType;
import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*; import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView; import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.textfield.TextFields; import org.controlsfx.control.textfield.TextFields;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -18,9 +30,11 @@ import tornadofx.control.Field;
import tornadofx.control.Fieldset; import tornadofx.control.Fieldset;
import tornadofx.control.Form; import tornadofx.control.Form;
import java.util.ArrayList; import java.io.File;
import java.util.List; import java.io.FileOutputStream;
import java.util.Locale; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class SearchWalletDialog extends Dialog<Entry> { public class SearchWalletDialog extends Dialog<Entry> {
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class); private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
@ -36,21 +50,17 @@ public class SearchWalletDialog extends Dialog<Entry> {
throw new IllegalArgumentException("No wallets selected to search"); throw new IllegalArgumentException("No wallets selected to search");
} }
boolean showWallet = walletForms.stream().map(WalletForm::getMasterWallet).distinct().limit(2).count() > 1;
boolean showAccount = walletForms.stream().anyMatch(walletForm -> !walletForm.getWallet().isMasterWallet() || !walletForm.getNestedWalletForms().isEmpty());
final DialogPane dialogPane = getDialogPane(); final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow()); AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Search Wallet"); dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet " + walletForms.get(0).getMasterWallet().getName());
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if(!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
VBox vBox = new VBox(); VBox vBox = new VBox();
vBox.setSpacing(20); vBox.setSpacing(20);
@ -69,23 +79,30 @@ public class SearchWalletDialog extends Dialog<Entry> {
fieldset.getChildren().addAll(searchField); fieldset.getChildren().addAll(searchField);
form.getChildren().add(fieldset); form.getChildren().add(fieldset);
boolean showWallet = walletForms.size() > 1 || walletForms.stream().anyMatch(walletForm -> !walletForm.getNestedWalletForms().isEmpty());
results = new CoinTreeTable(); results = new CoinTreeTable();
results.setTableType(TableType.SEARCH_WALLET);
results.setShowRoot(false); results.setShowRoot(false);
results.setPrefWidth(showWallet ? 950 : 850); results.setPrefWidth(showWallet || showAccount ? 950 : 850);
results.setUnitFormat(walletForms.iterator().next().getWallet()); results.setUnitFormat(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results")); results.setPlaceholder(new Label("No results"));
results.setEditable(true);
if(showWallet) { if(showWallet) {
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet"); TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> { walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName()); return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getMasterName());
}); });
results.getColumns().add(walletColumn); results.getColumns().add(walletColumn);
} }
if(showAccount) {
TreeTableColumn<Entry, String> accountColumn = new TreeTableColumn<>("Account");
accountColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
});
results.getColumns().add(accountColumn);
}
TreeTableColumn<Entry, String> typeColumn = new TreeTableColumn<>("Type"); TreeTableColumn<Entry, String> typeColumn = new TreeTableColumn<>("Type");
typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> { typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType()); return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType());
@ -105,7 +122,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> { labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return param.getValue().getValue().labelProperty(); return param.getValue().getValue().labelProperty();
}); });
labelCol.setCellFactory(p -> new SearchLabelCell()); labelCol.setCellFactory(p -> new LabelCell());
results.getColumns().add(labelCol); results.getColumns().add(labelCol);
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value"); TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
@ -118,12 +135,20 @@ public class SearchWalletDialog extends Dialog<Entry> {
vBox.getChildren().addAll(form, results); vBox.getChildren().addAll(form, results);
dialogPane.setContent(vBox); dialogPane.setContent(vBox);
ButtonType exportButtonType = new ButtonType("Export CSV", ButtonBar.ButtonData.LEFT);
ButtonType showButtonType = new javafx.scene.control.ButtonType("Show", ButtonBar.ButtonData.APPLY); ButtonType showButtonType = new javafx.scene.control.ButtonType("Show", ButtonBar.ButtonData.APPLY);
ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType, showButtonType); dialogPane.getButtonTypes().addAll(exportButtonType, cancelButtonType, showButtonType);
Button showButton = (Button) dialogPane.lookupButton(showButtonType); Button exportButton = (Button)dialogPane.lookupButton(exportButtonType);
exportButton.setGraphic(GlyphUtils.getDownArrowGlyph());
exportButton.addEventFilter(ActionEvent.ACTION, event -> {
event.consume();
exportResults(showWallet);
});
Button showButton = (Button)dialogPane.lookupButton(showButtonType);
showButton.setDefaultButton(true); showButton.setDefaultButton(true);
showButton.setDisable(true); showButton.setDisable(true);
@ -135,54 +160,56 @@ public class SearchWalletDialog extends Dialog<Entry> {
}); });
search.textProperty().addListener((observable, oldValue, newValue) -> { search.textProperty().addListener((observable, oldValue, newValue) -> {
searchWallet(newValue.toLowerCase(Locale.ROOT)); searchWallets(newValue);
}); });
SearchWalletEntry rootEntry = new SearchWalletEntry(walletForms.getFirst().getWallet(), Collections.emptyList());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
results.setRoot(rootItem);
setResizable(true); setResizable(true);
results.setupColumnWidths();
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(search::requestFocus); Platform.runLater(search::requestFocus);
} }
private void searchWallet(String searchText) { public List<WalletForm> getWalletForms() {
List<Entry> matchingEntries = new ArrayList<>(); return walletForms;
}
if(!searchText.isEmpty()) { private void searchWallets(String searchPhrase) {
Long searchValue = null; Set<Entry> matchingEntries = new LinkedHashSet<>();
try { if(!searchPhrase.isEmpty()) {
searchValue = Math.abs(Long.parseLong(searchText)); Set<String> searchWords = new LinkedHashSet<>(Arrays.stream(searchPhrase.split("\\s+"))
} catch(NumberFormatException e) { .filter(text -> isAddress(text) || isHash(text) || isHashIndex(text)).toList());
//ignore String freeText = removeOccurrences(searchPhrase, searchWords).trim();
if(!freeText.isEmpty()) {
searchWords.add(freeText);
} }
for(WalletForm walletForm : walletForms) { for(String searchText : searchWords) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry(); Long searchValue = getSearchValue(searchText);
for(Entry entry : walletTransactionsEntry.getChildren()) { Address searchAddress = getSearchAddress(searchText);
if(entry instanceof TransactionEntry transactionEntry) { searchText = searchText.toLowerCase(Locale.ROOT);
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
}
}
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { for(WalletForm walletForm : walletForms) {
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose); WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
for(Entry entry : purposeEntry.getChildren()) { for(Entry entry : walletTransactionsEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) { if(entry instanceof TransactionEntry transactionEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) || if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) || (transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) { (transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue) ||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
matchingEntries.add(entry); matchingEntries.add(entry);
} }
} }
} }
}
for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) { for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) { NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) { for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) { if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) || if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
@ -193,26 +220,147 @@ public class SearchWalletDialog extends Dialog<Entry> {
} }
} }
} }
}
WalletUtxosEntry walletUtxosEntry = walletForm.getWalletUtxosEntry(); for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) {
for(Entry entry : walletUtxosEntry.getChildren()) { for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) {
if(entry instanceof HashIndexEntry hashIndexEntry) { NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
if(hashIndexEntry.getBlockTransaction().getHash().toString().toLowerCase(Locale.ROOT).equals(searchText) || for(Entry entry : purposeEntry.getChildren()) {
(hashIndexEntry.getLabel() != null && hashIndexEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) || if(entry instanceof NodeEntry nodeEntry) {
(hashIndexEntry.getValue() != null && searchValue != null && Math.abs(hashIndexEntry.getValue()) == searchValue)) { if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
matchingEntries.add(entry); (nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
}
}
}
}
WalletUtxosEntry walletUtxosEntry = walletForm.getWalletUtxosEntry();
for(Entry entry : walletUtxosEntry.getChildren()) {
if(entry instanceof HashIndexEntry hashIndexEntry) {
if(hashIndexEntry.getBlockTransaction().getHash().toString().toLowerCase(Locale.ROOT).equals(searchText) ||
hashIndexEntry.getHashIndex().toString().toLowerCase(Locale.ROOT).equals(searchText) ||
(hashIndexEntry.getLabel() != null && hashIndexEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(hashIndexEntry.getValue() != null && searchValue != null && Math.abs(hashIndexEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
} }
} }
} }
} }
} }
SearchWalletEntry rootEntry = new SearchWalletEntry(walletForms.iterator().next().getWallet(), matchingEntries); SearchWalletEntry rootEntry = new SearchWalletEntry(walletForms.iterator().next().getWallet(), new ArrayList<>(matchingEntries));
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren); RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
results.setRoot(rootItem); results.setRoot(rootItem);
} }
private Long getSearchValue(String searchText) {
try {
return Math.abs(Long.parseLong(searchText));
} catch(NumberFormatException e) {
return null;
}
}
private Address getSearchAddress(String searchText) {
try {
return Address.fromString(searchText);
} catch(InvalidAddressException e) {
return null;
}
}
private boolean isAddress(String text) {
try {
Address.fromString(text);
return true;
} catch(InvalidAddressException e) {
return false;
}
}
private boolean isHash(String text) {
return text.length() == 64 && Utils.isHex(text);
}
private boolean isHashIndex(String text) {
String[] parts = text.split(":");
if(parts.length == 2 && isHash(parts[0])) {
try {
Integer.parseInt(parts[1]);
return true;
} catch(NumberFormatException e) {
//ignore
}
}
return false;
}
private String removeOccurrences(String inputString, Collection<String> stringsToRemove) {
for(String str : stringsToRemove) {
inputString = inputString.replaceAll("(?i)" + str, "");
}
return inputString;
}
public void exportResults(boolean showWallet) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export search results to CSV");
fileChooser.setInitialFileName(getDialogPane().getHeaderText() + ".csv");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8);
List<String> headers = new ArrayList<>(List.of("Wallet", "Account", "Type", "Date", "Txid / Address / Output", "Label", "Value"));
if(!showWallet) {
headers.remove(0);
}
writer.writeRecord(headers.toArray(new String[0]));
for(TreeItem<Entry> item : results.getRoot().getChildren()) {
Entry entry = item.getValue();
if(showWallet) {
writer.write(entry.getWallet().getMasterName());
}
writer.write(entry.getWallet().getDisplayName());
writer.write(entry.getEntryType());
if(entry instanceof TransactionEntry transactionEntry) {
writer.write(transactionEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate()));
writer.write(transactionEntry.getBlockTransaction().getHash().toString());
} else if(entry instanceof NodeEntry nodeEntry) {
writer.write("");
writer.write(nodeEntry.getAddress().toString());
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
writer.write(hashIndexEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(hashIndexEntry.getBlockTransaction().getDate()));
writer.write(hashIndexEntry.getHashIndex().toString());
} else {
writer.write("");
writer.write("");
}
writer.write(entry.getLabel());
writer.write(getCoinValue(entry.getValue() == null ? 0 : entry.getValue()));
writer.endRecord();
}
writer.close();
} catch(IOException e) {
log.error("Error exporting search results as CSV", e);
AppServices.showErrorDialog("Error exporting search results as CSV", e.getMessage());
}
}
}
private String getCoinValue(Long value) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return BitcoinUnit.BTC.equals(results.getBitcoinUnit()) ? format.tableFormatBtcValue(value) : String.format(Locale.ENGLISH, "%d", value);
}
private static class SearchWalletEntry extends Entry { private static class SearchWalletEntry extends Entry {
public SearchWalletEntry(Wallet wallet, List<Entry> entries) { public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
super(wallet, wallet.getName(), entries); super(wallet, wallet.getName(), entries);
@ -238,15 +386,19 @@ public class SearchWalletDialog extends Dialog<Entry> {
@Override @Override
protected void updateItem(Entry entry, boolean empty) { protected void updateItem(Entry entry, boolean empty) {
super.updateItem(entry, empty); super.updateItem(entry, empty);
setContextMenu(null);
}
}
private static class SearchLabelCell extends LabelCell { ContextMenu copyMenu;
@Override if(entry instanceof TransactionEntry transactionEntry) {
public void updateItem(String label, boolean empty) { copyMenu = new TransactionContextMenu(getText(), transactionEntry.getBlockTransaction());
super.updateItem(label, empty); } else if(entry instanceof NodeEntry nodeEntry) {
setContextMenu(null); copyMenu = new AddressContextMenu(nodeEntry.getAddress(), nodeEntry.getOutputDescriptor(), null, false, null);
} else if(entry instanceof UtxoEntry utxoEntry) {
copyMenu = new HashIndexEntryContextMenu(null, utxoEntry);
} else {
copyMenu = new ContextMenu();
}
copyMenu.getItems().removeIf(menuItem -> !menuItem.getText().startsWith("Copy"));
setContextMenu(copyMenu);
} }
} }
} }

View file

@ -1,19 +1,24 @@
package com.sparrowwallet.sparrow.control; package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane; import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane; import javafx.scene.layout.StackPane;
import java.util.Optional;
public class SeedDisplayDialog extends Dialog<Void> { public class SeedDisplayDialog extends Dialog<Void> {
public SeedDisplayDialog(Keystore decryptedKeystore) { public SeedDisplayDialog(Keystore decryptedKeystore) {
final DialogPane dialogPane = getDialogPane(); final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow()); AppServices.setStageIcon(dialogPane.getScene().getWindow());
int lines = decryptedKeystore.getSeed().getMnemonicCode().size() / 3; int lines = Math.ceilDiv(decryptedKeystore.getSeed().getMnemonicCode().size(), 3);
int height = lines * 40; int height = lines * 40;
StackPane stackPane = new StackPane(); StackPane stackPane = new StackPane();
@ -39,8 +44,19 @@ public class SeedDisplayDialog extends Dialog<Void> {
stackPane.getChildren().addAll(anchorPane); stackPane.getChildren().addAll(anchorPane);
if(decryptedKeystore.getSeed().getType() == DeterministicSeed.Type.BIP39) {
final ButtonType seedQRButtonType = new javafx.scene.control.ButtonType("Show SeedQR", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(seedQRButtonType);
Button seedQRButton = (Button)dialogPane.lookupButton(seedQRButtonType);
seedQRButton.addEventFilter(ActionEvent.ACTION, event -> {
event.consume();
showSeedQR(decryptedKeystore);
});
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType); dialogPane.getButtonTypes().add(cancelButtonType);
dialogPane.setPrefWidth(500); dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(150 + height); dialogPane.setPrefHeight(150 + height);
@ -48,4 +64,15 @@ public class SeedDisplayDialog extends Dialog<Void> {
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane)); Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
} }
private void showSeedQR(Keystore decryptedKeystore) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Sensitive QR", "The following QR contains these seed words. " +
"Be careful before displaying or digitally recording it.\n\nAre you sure you want to continue?", ButtonType.YES, ButtonType.NO);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
String seedQR = SeedQR.getSeedQR(decryptedKeystore.getSeed());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(seedQR);
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
qrDisplayDialog.showAndWait();
}
}
} }

View file

@ -0,0 +1,91 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.List;
public class SeedEntryDialog extends Dialog<List<String>> {
private final MnemonicKeystoreEntryPane keystorePane;
public SeedEntryDialog(String name, int numWords) {
final DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
int lines = numWords / 3;
int height = lines * 40;
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.getStyleClass().add("edge-to-edge");
scrollPane.setPrefHeight(104 + height);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
Accordion keystoreAccordion = new Accordion();
scrollPane.setContent(keystoreAccordion);
keystorePane = new MnemonicKeystoreEntryPane(name, numWords);
keystorePane.setAnimated(false);
keystoreAccordion.getPanes().add(keystorePane);
stackPane.getChildren().addAll(anchorPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
okButton.disableProperty().bind(keystorePane.validProperty().not());
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate New", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(generateButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? keystorePane.wordEntriesProperty.get() : null;
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(180 + height);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
}
private class MnemonicGridDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
keystorePane.generateNew();
});
button = generateButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
}
public boolean isGenerated() {
return keystorePane.isGenerated();
}
}

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