Compare commits

...
471 Commits
Author SHA1 Message Date
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
craigrawandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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
PrinceOfEgyptandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 88fc8f5017 Merge pull request #843 from secondl1ght/master
add StartupWMClass to linux .desktop file
2023-02-15 08:00:36 +02:00
secondl1ghtandGitHub 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
craigrawandGitHub 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
craigrawandGitHub 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 ChildsandGitHub 7ce7d37da7 Update bitcoin.lukechilds.co to bitcoin.lu.ke 2022-11-19 03:30:36 +07:00
BitcoinQnAandGitHub b422c754d6 Update Passport Single-sig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:52:58 +00:00
BitcoinQnAandGitHub be6e9019dc Update Passport Multisig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:49:26 +00:00
472 changed files with 21471 additions and 3984 deletions
+8 -5
View File
@@ -2,18 +2,21 @@ name: Package
on: workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
os: [windows-2022, ubuntu-20.04, macos-12]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up JDK 18.0.1
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '18.0.1'
@@ -28,7 +31,7 @@ jobs:
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Sparrow Build - ${{ runner.os }}
path: |
@@ -45,7 +48,7 @@ jobs:
run: for f in build/jpackage/sparrow*; do mv -v "$f" "${f/sparrow/sparrow-server}"; done;
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Sparrow Build - ${{ runner.os }} Headless
path: |
+2 -2
View File
@@ -16,7 +16,7 @@ or for those without SSH credentials:
`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 18 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).
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`
Java 17 or higher must be installed.
Java 18 or higher must be installed.
## Configuration
+210 -171
View File
@@ -2,12 +2,12 @@ import java.awt.GraphicsEnvironment
plugins {
id 'application'
id 'org-openjfx-javafxplugin'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.25.0'
id 'org-openjfx-javafxplugin'
id 'org.beryx.jlink' version '3.0.1'
}
def sparrowVersion = '1.7.1'
def sparrowVersion = '1.8.6'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -23,6 +23,19 @@ if(System.getProperty("os.arch") == "aarch64") {
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
def vTor = '4.7.13-4'
def vKmpTor = '1.4.3'
def kmpOs = osName
if(os.macOsX) {
kmpOs = "macos"
} else if(os.windows) {
kmpOs = "mingw"
}
def kmpArch = "x64"
if(System.getProperty("os.arch") == "aarch64") {
kmpArch = "arm64"
}
group "com.sparrowwallet"
version "${sparrowVersion}"
@@ -50,18 +63,19 @@ java {
dependencies {
//Any changes to the dependencies must be reflected in the module definitions below!
implementation(project(':drongo')) {
exclude group: 'org.hamcrest'
exclude group: 'junit'
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation(project(':drongo'))
implementation('com.google.guava:guava:33.0.0-jre')
implementation('com.google.code.gson:gson:2.9.1')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3')
implementation('com.zaxxer:HikariCP:4.0.3') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0')
implementation('org.jdbi:jdbi3-sqlobject:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:7.10.7-SNAPSHOT')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('no.tornado:tornadofx-controls:1.0.4')
@@ -69,14 +83,14 @@ dependencies {
exclude group: 'com.beust', module: 'jcommander'
}
implementation('com.beust:jcommander:1.81')
implementation('com.github.arteam:simple-json-rpc-core:1.0')
implementation('com.github.arteam:simple-json-rpc-client:1.0') {
implementation('com.github.arteam:simple-json-rpc-core:1.3')
implementation('com.github.arteam:simple-json-rpc-client:1.3') {
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'
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('com.sparrowwallet:hummingbird:1.7.3')
implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
@@ -84,10 +98,15 @@ dependencies {
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}${targetName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
implementation "io.matthewnelson.kotlin-components:kmp-tor:${vTor}-${vKmpTor}"
if(kmpOs == "linux" && kmpArch == "arm64") {
implementation("com.sparrowwallet.kmp-tor-binary:kmp-tor-binary-${kmpOs}${kmpArch}-jvm:${vTor}")
} else {
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-${kmpOs}${kmpArch}:${vTor}")
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-extract:${vTor}")
implementation("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager:${vKmpTor}")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.1')
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
@@ -100,24 +119,27 @@ dependencies {
}
implementation('dev.bwt:bwt-jni:0.1.8')
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'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.33')
implementation('com.sparrowwallet.bokmakierie:bokmakierie:1.0')
implementation('io.samourai.code.whirlpool:whirlpool-client:1.0.6')
implementation('io.samourai.code.wallet:java-http-client:2.0.2')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('org.apache.commons:commons-compress:1.25.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('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2')
testImplementation('junit:junit:4.12')
}
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
implementation('com.sparrowwallet:toucan:0.9.0')
implementation('com.jcraft:jzlib:1.1.3')
testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
}
compileJava {
@@ -138,10 +160,14 @@ processResources {
}
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"]
}
run {
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
@@ -159,8 +185,11 @@ run {
"--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.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.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"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
@@ -184,8 +213,6 @@ jlink {
uses 'org.flywaydb.core.extensibility.FlywayExtension'
uses 'org.flywaydb.core.internal.database.DatabaseType'
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/*']
@@ -206,17 +233,23 @@ jlink {
"--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/javafx.scene.input=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.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.sql",
"--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.annotation",
"--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=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
@@ -235,7 +268,7 @@ jlink {
appVersion = "${sparrowVersion}"
skipInstaller = os.macOsX || properties.skipInstallers
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) {
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']
@@ -258,6 +291,16 @@ jlink {
}
}
tasks.jlink.finalizedBy "addUserWritePermission"
task addUserWritePermission(type: Exec) {
if(os.windows) {
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', 'Users:(OI)(CI)F', '/T'
} else {
commandLine 'chmod', '-R', 'u+w', "$buildDir/image/legal"
}
}
task removeGroupWritePermission(type: Exec) {
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
}
@@ -282,7 +325,7 @@ task packageTarDistribution(type: Tar) {
}
extraJavaModuleInfo {
module('jackson-core-2.10.1.jar', 'com.fasterxml.jackson.core', '2.10.1') {
module('jackson-core-2.13.2.jar', 'com.fasterxml.jackson.core', '2.13.2') {
exports('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.core.async')
exports('com.fasterxml.jackson.core.base')
@@ -297,11 +340,11 @@ extraJavaModuleInfo {
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') {
module('jackson-annotations-2.13.2.jar', 'com.fasterxml.jackson.annotation', '2.13.2') {
requires('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.annotation')
}
module('jackson-databind-2.10.1.jar', 'com.fasterxml.jackson.databind', '2.10.1') {
module('jackson-databind-2.13.2.jar', 'com.fasterxml.jackson.databind', '2.13.2') {
requires('java.desktop')
requires('java.logging')
requires('com.fasterxml.jackson.annotation')
@@ -335,7 +378,7 @@ extraJavaModuleInfo {
exports('tornadofx.control')
requires('javafx.controls')
}
module('simple-json-rpc-core-1.0.jar', 'simple.json.rpc.core', '1.0') {
module('simple-json-rpc-core-1.3.jar', 'simple.json.rpc.core', '1.3') {
exports('com.github.arteam.simplejsonrpc.core.annotation')
exports('com.github.arteam.simplejsonrpc.core.domain')
requires('com.fasterxml.jackson.core')
@@ -343,7 +386,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind')
requires('org.jetbrains.annotations')
}
module('simple-json-rpc-client-1.0.jar', 'simple.json.rpc.client', '1.0') {
module('simple-json-rpc-client-1.3.jar', 'simple.json.rpc.client', '1.3') {
exports('com.github.arteam.simplejsonrpc.client')
exports('com.github.arteam.simplejsonrpc.client.builder')
exports('com.github.arteam.simplejsonrpc.client.exception')
@@ -351,7 +394,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind')
requires('simple.json.rpc.core')
}
module('simple-json-rpc-server-1.0.jar', 'simple.json.rpc.server', '1.0') {
module('simple-json-rpc-server-1.3.jar', 'simple.json.rpc.server', '1.3') {
exports('com.github.arteam.simplejsonrpc.server')
requires('simple.json.rpc.core')
requires('com.google.common')
@@ -380,46 +423,16 @@ extraJavaModuleInfo {
module('javacsv-2.0.jar', 'net.sourceforge.javacsv', '2.0') {
exports('com.csvreader')
}
module('jul-to-slf4j-1.7.30.jar', 'org.slf4j.jul.to.slf4j', '1.7.30') {
exports('org.slf4j.bridge')
requires('java.logging')
requires('org.slf4j')
}
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')
exports('org.json.simple.parser')
}
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('j2objc-annotations-2.8.jar', 'com.google.j2objc.j2objc.annotations', '2.8')
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')
@@ -430,6 +443,7 @@ extraJavaModuleInfo {
requires('io.leangen.geantyref')
requires('java.sql')
requires('org.slf4j')
requires('com.github.benmanes.caffeine')
}
module('geantyref-1.3.11.jar', 'io.leangen.geantyref', '1.3.11') {
exports('io.leangen.geantyref')
@@ -478,13 +492,6 @@ extraJavaModuleInfo {
requires('javafx.graphics')
}
module('jai-imageio-core-1.4.0.jar', 'com.github.jai.imageio.jai.imageio.core', '1.4.0')
module('kotlin-stdlib-jdk8-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk8', '1.5.20')
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') {
exports('com.sparrowwallet.hummingbird')
exports('com.sparrowwallet.hummingbird.registry')
@@ -492,66 +499,24 @@ extraJavaModuleInfo {
}
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
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') {
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('commons-codec-1.10.jar', 'commons.codec', '1.10') {
exports('org.apache.commons.codec')
}
module('throwing-supplier-1.0.3.jar', 'zeroleak.throwingsupplier', '1.0.3') {
exports('com.zeroleak.throwingsupplier')
module('logback-core-1.2.13.jar', 'ch.qos.logback.core', '1.2.13') {
exports('ch.qos.logback.core')
}
module('okhttp-2.7.5.jar', 'com.squareup.okhttp', '2.7.5') {
exports('com.squareup.okhttp')
module('jackson-datatype-jsr310-2.13.2.jar', 'jackson-datatype-jsr310', '2.13.2') {
exports('com.fasterxml.jackson.datatype.jsr310')
}
module('json-20240205.jar', 'org.json', '20240205') {
exports('org.json')
}
module('scrypt-1.4.0.jar', 'scrypt', '1.4.0') {
exports('com.lambdaworks.codec')
exports('com.lambdaworks.crypto')
}
module('okio-1.6.0.jar', 'com.squareup.okio', '1.6.0') {
exports('okio')
@@ -559,22 +524,12 @@ extraJavaModuleInfo {
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')
exports('java8.util')
exports('java8.util.function')
exports('java8.util.stream')
}
module('protobuf-java-2.6.1.jar', 'com.google.protobuf', '2.6.1') {
exports('com.google.protobuf')
}
module('commons-text-1.2.jar', 'org.apache.commons.text', '1.2') {
exports('org.apache.commons.text')
}
@@ -590,40 +545,124 @@ extraJavaModuleInfo {
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')
module("kmp-tor-jvm-${vKmpTor}.jar", 'kmp.tor.jvm', "${vTor}-${vKmpTor}") {
exports('io.matthewnelson.kmp.tor')
requires('kmp.tor.binary.extract.jvm')
requires('kmp.tor.manager.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('commons.compress')
requires('org.tukaani.xz')
requires('kotlinx.coroutines.core')
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')
if(kmpOs == "linux" && kmpArch == "arm64") {
module("kmp-tor-binary-${kmpOs}${kmpArch}-jvm-${vTor}.jar", "kmp.tor.binary.${kmpOs}${kmpArch}", "${vTor}") {
exports("io.matthewnelson.kmp.tor.resource.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
} else {
module("kmp-tor-binary-${kmpOs}${kmpArch}-jvm-${vTor}.jar", "kmp.tor.binary.${kmpOs}${kmpArch}", "${vTor}") {
exports("io.matthewnelson.kmp.tor.binary.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
}
module('commons-compress-1.18.jar', 'commons.compress', '1.18') {
exports('org.apache.commons.compress')
requires('org.tukaani.xz')
module("kmp-tor-binary-extract-jvm-${vTor}.jar", 'kmp.tor.binary.extract.jvm', "${vTor}") {
exports('io.matthewnelson.kmp.tor.binary.extract')
exports('io.matthewnelson.kmp.tor.binary.extract.internal')
requires('kotlin.stdlib')
requires("kmp.tor.binary.${kmpOs}${kmpArch}")
requires('kmp.tor.binary.geoip.jvm')
}
module('xz-1.6.jar', 'org.tukaani.xz', '1.6') {
exports('org.tukaani.xz')
module("kmp-tor-manager-jvm-${vKmpTor}.jar", 'kmp.tor.manager.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.manager')
exports('io.matthewnelson.kmp.tor.manager.util')
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core')
requires('kotlinx.atomicfu')
requires('kmp.tor.controller.jvm')
requires('kmp.tor.common.jvm')
}
module('jsocks-1.0.jar', 'com.github.ravn.jsocks', '1.0') {
exports('com.runjva.sourceforge.jsocks.protocol')
requires('org.slf4j')
module("kmp-tor-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.manager.common')
exports('io.matthewnelson.kmp.tor.manager.common.event')
exports('io.matthewnelson.kmp.tor.manager.common.state')
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.common.jvm')
requires('kotlin.stdlib')
}
module("kmp-tor-controller-common-jvm-${vKmpTor}.jar", 'kmp.tor.controller.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.controller.common.config')
exports('io.matthewnelson.kmp.tor.controller.common.file')
exports('io.matthewnelson.kmp.tor.controller.common.control')
exports('io.matthewnelson.kmp.tor.controller.common.control.usecase')
exports('io.matthewnelson.kmp.tor.controller.common.events')
exports('io.matthewnelson.kmp.tor.controller.common.exceptions')
requires('kmp.tor.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
}
module("kmp-tor-common-jvm-${vKmpTor}.jar", 'kmp.tor.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.common.address')
requires('parcelize.jvm')
requires('kotlin.stdlib')
}
module("kmp-tor-controller-jvm-${vKmpTor}.jar", 'kmp.tor.controller.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.controller.internal.controller')
requires('kmp.tor.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlinx.coroutines.core')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
requires('encoding.core.jvm')
requires('encoding.base16.jvm')
}
module("kmp-tor-ext-callback-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.common')
}
module("kmp-tor-ext-callback-manager-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager')
requires('kmp.tor.manager.jvm')
requires('kmp.tor.ext.callback.common.jvm')
requires('kmp.tor.ext.callback.manager.common.jvm')
requires('kmp.tor.ext.callback.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core')
}
module("kmp-tor-ext-callback-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager.common')
requires('kmp.tor.ext.callback.controller.common.jvm')
}
module("kmp-tor-ext-callback-controller-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.controller.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control')
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control.usecase')
}
module("kmp-tor-binary-geoip-jvm-${vTor}.jar", 'kmp.tor.binary.geoip.jvm', "${vTor}") {
exports('io.matthewnelson.kmp.tor.binary.geoip')
exports('kmptor')
}
module("base16-jvm-2.0.0.jar", 'encoding.base16.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.base16')
requires('encoding.core.jvm')
requires('kotlin.stdlib')
}
module("base32-jvm-2.0.0.jar", 'encoding.base32.jvm', "2.0.0")
module("base64-jvm-2.0.0.jar", 'encoding.base64.jvm', "2.0.0")
module("core-jvm-2.0.0.jar", 'encoding.core.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.core')
requires('kotlin.stdlib')
}
module("parcelize-jvm-0.1.2.jar", 'parcelize.jvm', "0.1.2") {
exports('io.matthewnelson.component.parcelize')
}
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')
}
module('junit-4.12.jar', 'junit', '4.12') {
exports('org.junit')
requires('org.hamcrest.core')
module('jzlib-1.1.3.jar', 'com.jcraft.jzlib', '1.1.3') {
exports('com.jcraft.jzlib')
}
module('hamcrest-core-1.3.jar', 'org.hamcrest.core', '1.3')
}
+3 -3
View File
@@ -3,9 +3,9 @@ plugins {
}
dependencies {
implementation 'org.ow2.asm:asm:8.0.1'
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.0'
implementation 'org.javamodularity:moduleplugin:1.8.12'
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.3'
implementation 'org.javamodularity:moduleplugin:1.8.14'
implementation 'org.ow2.asm:asm:9.6'
}
repositories {
+2 -2
View File
@@ -42,7 +42,7 @@ Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
```
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:
```sh
@@ -82,7 +82,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:
```shell
GIT_TAG="1.7.1"
GIT_TAG="1.8.5"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: fa18ec9d45...3a2344f129
Binary file not shown.
+3 -1
View File
@@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+22 -13
View File
@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (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/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,13 +80,11 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -133,22 +131,29 @@ location of your Java installation."
fi
else
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
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
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 ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | 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" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_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 -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
Vendored
+1
View File
@@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
+3
View File
@@ -0,0 +1,3 @@
mime-type=application/pgp-signature
extension=asc
description=ASCII Armored File
@@ -5,5 +5,6 @@ Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
Categories=Finance;Network;
MimeType=application/psbt;application/bitcoin-transaction;application/pgp-signature;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
StartupWMClass=Sparrow
+16 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.7.1</string>
<string>1.8.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -94,6 +94,21 @@
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</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>
<key>CFBundleDocumentTypes</key>
<array>
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,6 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Application;
@@ -34,6 +33,7 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.fxml.FXMLLoader;
@@ -50,7 +50,6 @@ import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -64,11 +63,14 @@ import java.io.IOException;
import java.net.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
public class AppServices {
private static final Logger log = LoggerFactory.getLogger(AppServices.class);
@@ -79,13 +81,17 @@ public class AppServices {
private static final int RATES_PERIOD_SECS = 5 * 60;
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
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 Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
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<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);
public static final List<Long> LONG_FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L, 2048L, 4096L, 8192L);
public static final List<Long> FEE_RATES_RANGE = LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
private static AppServices INSTANCE;
@@ -95,7 +101,7 @@ public class AppServices {
private InteractionServices interactionServices;
private static PayNymService payNymService;
private static HttpClientService httpClientService;
private final Application application;
@@ -111,6 +117,8 @@ public class AppServices {
private ElectrumServer.ConnectionService connectionService;
private ElectrumServer.FeeRatesService feeRatesService;
private Hwi.ScheduledEnumerateService deviceEnumerateService;
private VersionCheckService versionCheckService;
@@ -159,6 +167,11 @@ public class AppServices {
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
if(httpClientService != null) {
HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
shutdownService.start();
}
}
}
};
@@ -182,6 +195,7 @@ public class AppServices {
public void start() {
Config config = Config.get();
connectionService = createConnectionService();
feeRatesService = createFeeRatesService();
ratesService = createRatesService(config.getExchangeSource(), config.getFiatCurrency());
versionCheckService = createVersionCheckService();
torService = createTorService();
@@ -195,6 +209,8 @@ public class AppServices {
} else {
restartServices();
}
} else {
EventManager.get().post(new DisconnectionEvent());
}
addURIHandlers();
@@ -246,13 +262,13 @@ public class AppServices {
versionCheckService.cancel();
}
if(payNymService != null) {
PayNymService.ShutdownService shutdownService = new PayNymService.ShutdownService(payNymService);
if(httpClientService != null) {
HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
shutdownService.start();
}
if(Tor.getDefault() != null) {
Tor.getDefault().shutdown();
Tor.getDefault().getTorManager().destroy(true, success -> {});
}
}
@@ -278,8 +294,15 @@ public class AppServices {
onlineProperty.setValue(true);
onlineProperty.addListener(onlineServicesListener);
if(connectionService.getValue() != null) {
EventManager.get().post(connectionService.getValue());
FeeRatesUpdatedEvent event = connectionService.getValue();
if(event != null) {
EventManager.get().post(event);
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(event instanceof ConnectionEvent && Network.get().equals(Network.MAINNET) && feeRatesSource.isExternal()) {
EventManager.get().post(new FeeRatesSourceChangedEvent(feeRatesSource));
}
});
connectionService.setOnFailed(failEvent -> {
@@ -290,20 +313,19 @@ public class AppServices {
connectionService.setRestartOnFailure(false);
}
if(failEvent.getSource().getException() instanceof TlsServerException && failEvent.getSource().getException().getCause() != null) {
TlsServerException tlsServerException = (TlsServerException)failEvent.getSource().getException();
if(failEvent.getSource().getException() instanceof TlsServerException tlsServerException && failEvent.getSource().getException().getCause() != null) {
connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert();
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() + "." +
"\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.");
} else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
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." +
"\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);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
if(crtFile.delete()) {
@@ -315,10 +337,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);
Platform.runLater(() -> restartService(torService));
return;
@@ -342,10 +373,21 @@ public class AppServices {
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) {
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(
exchangeSource == null ? DEFAULT_EXCHANGE_SOURCE : exchangeSource,
currency == null ? DEFAULT_FIAT_CURRENCY : currency);
//Delay startup on first run, Windows requires a longer delay
ratesService.setDelay(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS ? Duration.seconds(RATES_DELAY_SECS_WINDOWS) : Duration.seconds(RATES_DELAY_SECS_DEFAULT));
ratesService.setPeriod(Duration.seconds(RATES_PERIOD_SECS));
ratesService.setRestartOnFailure(true);
@@ -406,23 +448,6 @@ public class AppServices {
EventManager.get().post(new TorReadyStatusEvent());
});
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()));
});
@@ -477,13 +502,12 @@ public class AppServices {
public static Proxy getProxy(String proxyCircuitId) {
Config config = Config.get();
Proxy proxy = null;
if(config.isUseProxy()) {
if(config.isUseProxy() && config.getProxyServer() != null) {
HostAndPort proxyHostAndPort = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
} else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
proxy = Tor.getDefault().getProxy();
}
//Setting new proxy authentication credentials will force a new Tor circuit to be created
@@ -522,23 +546,22 @@ public class AppServices {
return get().interactionServices;
}
public static PayNymService getPayNymService() {
if(payNymService == null) {
HostAndPort torProxy = getTorProxy();
payNymService = new PayNymService(torProxy);
public static HttpClientService getHttpClientService() {
HostAndPort torProxy = getTorProxy();
if(httpClientService == null) {
httpClientService = new HttpClientService(torProxy);
} else {
HostAndPort torProxy = getTorProxy();
if(!Objects.equals(payNymService.getTorProxy(), torProxy)) {
payNymService.setTorProxy(getTorProxy());
if(!Objects.equals(httpClientService.getTorProxy(), torProxy)) {
httpClientService.setTorProxy(getTorProxy());
}
}
return payNymService;
return httpClientService;
}
public static HostAndPort getTorProxy() {
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()));
}
@@ -574,8 +597,17 @@ public class AppServices {
return windowIcon;
}
public static boolean isReducedWindowHeight() {
Window activeWindow = getActiveWindow();
return (activeWindow != null && activeWindow.getHeight() < getReducedWindowHeight());
}
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 org.controlsfx.tools.Platform.getCurrent() != org.controlsfx.tools.Platform.OSX ? 802d : 768d; //Check for menu bar of ~34px
}
public Application getApplication() {
@@ -662,15 +694,19 @@ public class AppServices {
public static Double getDefaultFeeRate() {
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() {
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);
}
public static double getFallbackFeeRate() {
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
}
public static Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
@@ -680,6 +716,10 @@ public class AppServices {
}
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
if(rateSizes.isEmpty()) {
return;
}
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
if(mempoolHistogram.isEmpty()) {
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
@@ -689,6 +729,12 @@ public class AppServices {
Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
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 getMinimumRelayFeeRate() {
@@ -766,7 +812,7 @@ public class AppServices {
}
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) {
@@ -803,6 +849,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) {
for(String fileUri : fileUriArguments) {
try {
@@ -821,6 +885,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) {
openFiles(argFiles, window);
argFiles.clear();
@@ -840,6 +923,7 @@ public class AppServices {
}
if(openWindow instanceof Stage) {
((Stage)openWindow).setIconified(false);
((Stage)openWindow).setAlwaysOnTop(true);
((Stage)openWindow).setAlwaysOnTop(false);
}
@@ -847,6 +931,8 @@ public class AppServices {
for(File file : openFiles) {
if(isWalletFile(file)) {
EventManager.get().post(new RequestWalletOpenEvent(openWindow, file));
} else if(isVerifyDownloadFile(file)) {
EventManager.get().post(new RequestVerifyDownloadEvent(openWindow, file));
} else {
EventManager.get().post(new RequestTransactionOpenEvent(openWindow, file));
}
@@ -890,7 +976,7 @@ public class AppServices {
if(wallet != null) {
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()))));
}
} catch(Exception e) {
@@ -930,6 +1016,7 @@ public class AppServices {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(getActiveWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
@@ -992,6 +1079,7 @@ public class AppServices {
wallet = wallets.iterator().next();
} else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
walletChoiceDialog.initOwner(getActiveWindow());
walletChoiceDialog.setTitle("Choose Wallet");
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
Image image = new Image("/image/sparrow-small.png");
@@ -1015,8 +1103,6 @@ public class AppServices {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
@@ -1039,17 +1125,22 @@ public class AppServices {
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
}
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
if(event.getMempoolRateSizes() != null) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
}
@Subscribe
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
feeRatesService.start();
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
}
@Subscribe
@@ -1104,7 +1195,7 @@ public class AppServices {
Wallet wallet = walletTabData.getWallet();
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;
if(deviceEnumerateService == null) {
@@ -12,7 +12,12 @@ import org.fxmisc.richtext.event.MouseOverTextEvent;
import org.fxmisc.richtext.model.TwoDimensional;
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;
public abstract class BaseController {
@@ -24,14 +29,11 @@ public abstract class BaseController {
scriptArea.setMouseOverTextDelay(Duration.ofMillis(150));
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
TwoDimensional.Position position = scriptArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
if(position.getMajor() % 2 == 0) {
ScriptChunk hoverChunk = scriptArea.getScript().getChunks().get(position.getMajor()/2);
if(!hoverChunk.isOpCode()) {
Point2D pos = e.getScreenPosition();
popupMsg.setText(describeScriptChunk(hoverChunk));
popup.show(scriptArea, pos.getX(), pos.getY() + 10);
}
ScriptChunk hoverChunk = getScriptChunk(scriptArea, e.getCharacterIndex());
if(hoverChunk != null) {
Point2D pos = e.getScreenPosition();
popupMsg.setText(describeScriptChunk(hoverChunk));
popup.show(scriptArea, pos.getX(), pos.getY() + 10);
}
});
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
@@ -80,4 +82,26 @@ public abstract class BaseController {
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;
}
}
@@ -15,13 +15,13 @@ import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
import static com.sparrowwallet.sparrow.AppServices.*;
public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
alert.initOwner(getActiveWindow());
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
@@ -53,6 +53,8 @@ public class DefaultInteractionServices implements InteractionServices {
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
alert.setResizable(true);
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@@ -60,6 +62,7 @@ public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
passphraseDialog.initOwner(getActiveWindow());
return passphraseDialog.showAndWait();
}
}
@@ -45,6 +45,7 @@ public class SparrowDesktop extends Application {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
AppServices.initialize(this);
@@ -79,6 +80,7 @@ public class SparrowDesktop extends Application {
}
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) {
mainStage.setWidth(Config.get().getAppWidth());
@@ -106,7 +108,7 @@ public class SparrowDesktop extends Application {
}
}
AppServices.openFileUriArguments(stage);
AppServices.openFileUriArgumentsAfterWalletLoading(stage);
AppServices.get().start();
}
@@ -16,9 +16,9 @@ import java.io.File;
import java.util.*;
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_VERSION = "1.7.1";
public static final String APP_VERSION = "1.8.6";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -79,7 +79,7 @@ public class SparrowWallet {
try {
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) {
getLogger().error("Could not access application lock", e);
}
@@ -130,13 +130,13 @@ public class SparrowWallet {
private final List<String> fileUriArguments;
public Instance(List<String> fileUriArguments) {
super(SparrowWallet.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty());
super(SparrowWallet.APP_ID, true);
this.fileUriArguments = fileUriArguments;
}
@Override
protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) {
if(messageList != null) {
AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null);
}
@@ -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));
}
}
@@ -12,6 +12,7 @@ public enum UnitFormat {
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
@@ -30,6 +31,10 @@ public enum UnitFormat {
return currencyFormat;
}
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator('.');
@@ -42,6 +47,7 @@ public enum UnitFormat {
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
@@ -60,6 +66,10 @@ public enum UnitFormat {
return currencyFormat;
}
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator(',');
@@ -78,10 +88,16 @@ public enum UnitFormat {
public abstract DecimalFormat getCurrencyFormat();
public abstract DecimalFormat getTableCurrencyFormat();
public String formatBtcValue(Long amount) {
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) {
return getSatsFormat().format(amount);
}
@@ -90,6 +106,10 @@ public enum UnitFormat {
return getCurrencyFormat().format(amount);
}
public String tableFormatCurrencyValue(double amount) {
return getTableCurrencyFormat().format(amount);
}
public String getGroupingSeparator() {
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
}
@@ -22,6 +22,7 @@ public class WelcomeDialog extends Dialog<Mode> {
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
@@ -14,10 +14,13 @@ import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
import static com.sparrowwallet.drongo.wallet.StandardAccount.*;
public class AddAccountDialog extends Dialog<List<StandardAccount>> {
private static final int MAX_SHOWN_ACCOUNTS = 8;
private final ComboBox<StandardAccount> standardAccountCombo;
private boolean discoverAccounts = false;
@@ -42,29 +45,32 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
standardAccountCombo = new ComboBox<>();
standardAccountCombo.setMaxWidth(Double.MAX_VALUE);
List<Integer> existingIndexes = new ArrayList<>();
Set<Integer> existingIndexes = new LinkedHashSet<>();
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
existingIndexes.add(masterWallet.getAccountIndex());
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isNested()) {
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<>();
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);
}
}
if(WhirlpoolServices.canWalletMix(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
availableAccounts.add(StandardAccount.WHIRLPOOL_PREMIX);
availableAccounts.add(WHIRLPOOL_PREMIX);
} else if(WhirlpoolServices.canWatchPostmix(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
availableAccounts.add(WHIRLPOOL_POSTMIX);
}
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
if(!availableAccounts.isEmpty() && Config.get().getServerType() != ServerType.BITCOIN_CORE &&
(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
if(!availableAccounts.isEmpty() && (masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
|| (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) {
dialogPane.getButtonTypes().add(discoverButtonType);
Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType);
@@ -82,10 +88,14 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
return "None Available";
}
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(account)) {
if(account == WHIRLPOOL_PREMIX) {
return "Whirlpool Accounts";
}
if(account == WHIRLPOOL_POSTMIX) {
return "Whirlpool Postmix (No mixing)";
}
return account.getName();
}
@@ -40,7 +40,7 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
if(utxoEntry != null) {
Address address = addressStatus.getAddress();
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.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
@@ -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)), "image/" + importer.getWalletModel().getType() + ".png");
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);
}
};
}
}
}
@@ -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) { }
}
@@ -2,27 +2,35 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.Region;
import javafx.util.Duration;
import org.controlsfx.tools.Platform;
import java.text.DecimalFormat;
class CoinCell extends TreeTableCell<Entry, Number> {
private final Tooltip tooltip;
class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsListener {
private final CoinTooltip tooltip;
private final CoinContextMenu contextMenu;
private IntegerProperty confirmationsProperty;
public CoinCell() {
super();
tooltip = new Tooltip();
tooltip = new CoinTooltip();
tooltip.setShowDelay(Duration.millis(500));
contextMenu = new CoinContextMenu();
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
getStyleClass().add("number-field");
@@ -37,6 +45,7 @@ class CoinCell extends TreeTableCell<Entry, Number> {
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
} else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
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);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
setText(btcValue);
} else {
tooltip.setText(btcValue + " " + BitcoinUnit.BTC.getLabel());
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
setText(satsValue);
}
setTooltip(tooltip);
String tooltipValue = tooltip.getText();
contextMenu.updateAmount(amount);
setContextMenu(contextMenu);
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
});
tooltip.showConfirmations(transactionEntry.confirmationsProperty(), transactionEntry.isCoinbase());
if(transactionEntry.isConfirming()) {
ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations());
@@ -94,4 +100,99 @@ 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();
}
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);
}
}
}
@@ -12,7 +12,7 @@ import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
}
public UnitFormat getUnitFormat() {
@@ -29,7 +29,7 @@ public class CoinTextFormatter extends TextFormatter<String> {
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -17,6 +18,7 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import javafx.scene.layout.StackPane;
@@ -28,6 +30,7 @@ import java.util.Optional;
public class CoinTreeTable extends TreeTableView<Entry> {
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
private CurrencyRate currencyRate;
public BitcoinUnit getBitcoinUnit() {
return bitcoinUnit;
@@ -64,6 +67,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) {
if(getRoot() != null) {
Entry entry = getRoot().getValue();
@@ -93,7 +108,8 @@ public class CoinTreeTable extends TreeTableView<Entry> {
Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30);
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();
if(optDate.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
@@ -119,4 +135,12 @@ public class CoinTreeTable extends TreeTableView<Entry> {
stackPane.setAlignment(Pos.CENTER);
return stackPane;
}
public void setSortColumn(int columnIndex, TreeTableColumn.SortType sortType) {
if(columnIndex >= 0 && columnIndex < getColumns().size() && getSortOrder().isEmpty() && !getRoot().getChildren().isEmpty()) {
TreeTableColumn<Entry, ?> column = getColumns().get(columnIndex);
column.setSortType(sortType == null ? TreeTableColumn.SortType.DESCENDING : sortType);
getSortOrder().add(column);
}
}
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.property.IntegerProperty;
public interface ConfirmationsListener {
IntegerProperty getConfirmationsProperty();
}
@@ -10,12 +10,16 @@ import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.event.EventHandler;
import javafx.scene.input.MouseEvent;
public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
private final CoinContextMenu contextMenu;
private BitcoinUnit bitcoinUnit;
public CopyableCoinLabel() {
this("Unknown");
}
@@ -23,6 +27,21 @@ public class CopyableCoinLabel extends CopyableLabel {
public CopyableCoinLabel(String text) {
super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
setOnMouseClicked(event -> {
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();
contextMenu = new CoinContextMenu();
}
@@ -63,6 +82,8 @@ public class CopyableCoinLabel extends CopyableLabel {
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
}
this.bitcoinUnit = unit;
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue);
setText(btcValue);
@@ -10,6 +10,7 @@ import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
@@ -52,6 +53,7 @@ public class CopyableTextField extends CustomTextField {
selectedTextProperty().removeListener(selectionListener);
}
});
setContextMenu(new ContextMenu());
}
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
@@ -12,6 +11,8 @@ import javafx.util.Duration;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import static com.sparrowwallet.sparrow.control.EntryCell.HashIndexEntryContextMenu;
public class DateCell extends TreeTableCell<Entry, Entry> {
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;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
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) {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
setContextMenu(new DateContextMenu(getTreeTableView(), utxoEntry, date));
} else {
setText("Unknown");
setContextMenu(null);
@@ -56,8 +57,10 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
}
}
private static class DateContextMenu extends ContextMenu {
public DateContextMenu(String date, BlockTransactionHashIndex reference) {
private static class DateContextMenu extends HashIndexEntryContextMenu {
public DateContextMenu(TreeTableView<Entry> treeTableView, UtxoEntry utxoEntry, String date) {
super(treeTableView, utxoEntry);
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -70,7 +73,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
copyHeight.setOnAction(AE -> {
hide();
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);
});
@@ -68,7 +68,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
stackPane.getChildren().addAll(anchorPane, scanBox);
List<Device> devices = AppServices.getDevices();
List<Device> devices = getDevices();
if(devices == null || devices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
@@ -91,11 +91,16 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
}
protected List<Device> getDevices() {
return AppServices.getDevices();
}
private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
@@ -9,11 +9,11 @@ import com.sparrowwallet.sparrow.io.Device;
import java.util.stream.Collectors;
public class DeviceAddressDialog extends DeviceDialog<String> {
public class DeviceDisplayAddressDialog extends DeviceDialog<String> {
private final Wallet wallet;
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()));
this.wallet = wallet;
this.outputDescriptor = outputDescriptor;
@@ -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());
}
}
@@ -1,25 +1,33 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.application.Platform;
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.Pos;
import javafx.scene.Node;
@@ -34,8 +42,10 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class DevicePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
@@ -58,8 +68,12 @@ public class DevicePane extends TitledDescriptionPane {
private Button displayAddressButton;
private Button signMessageButton;
private Button discoverKeystoresButton;
private ButtonBase getPrivateKeyButton;
private Button getAddressButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
private final SimpleStringProperty pin = new SimpleStringProperty("");
private final StringProperty messageProperty = new SimpleStringProperty("");
private boolean defaultDevice;
@@ -83,13 +97,17 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
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");
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null;
this.wallet = wallet;
this.psbt = psbt;
this.outputDescriptor = null;
this.keyDerivation = null;
@@ -106,6 +124,10 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
}
@@ -152,6 +174,10 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
@@ -178,6 +204,41 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton);
}
public DevicePane(DeviceOperation deviceOperation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
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) {
if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice);
@@ -201,7 +262,7 @@ public class DevicePane extends TitledDescriptionPane {
}
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() {
@@ -239,7 +300,7 @@ public class DevicePane extends TitledDescriptionPane {
if(importButton instanceof SplitMenuButton importMenuButton) {
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) {
MenuItem item = new MenuItem(scriptType.getDescription());
final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
@@ -321,6 +382,60 @@ public class DevicePane extends TitledDescriptionPane {
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) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
@@ -554,7 +669,36 @@ public class DevicePane extends TitledDescriptionPane {
}
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());
enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue();
@@ -591,18 +735,7 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
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));
}
importKeystore(derivation, keystore);
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
@@ -616,20 +749,74 @@ public class DevicePane extends TitledDescriptionPane {
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() {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt);
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
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);
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
});
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 -> {
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();
service.start();
}
private void displayAddress() {
@@ -647,17 +834,32 @@ public class DevicePane extends TitledDescriptionPane {
}
private void signMessage() {
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();
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty);
handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
} catch(Exception e) {
log.error("Signing Error: " + e.getMessage(), e);
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() {
@@ -717,6 +919,55 @@ public class DevicePane extends TitledDescriptionPane {
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() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
@@ -724,7 +975,7 @@ public class DevicePane extends TitledDescriptionPane {
}
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(true);
showHideLink.setVisible(!device.isCard());
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
@@ -743,6 +994,16 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setDefaultButton(defaultDevice);
discoverKeystoresButton.setVisible(true);
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 +1011,7 @@ public class DevicePane extends TitledDescriptionPane {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
derivationField.setDisable(keyDerivation != null);
derivationField.setDisable(device.isCard() || keyDerivation != null);
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
@@ -785,11 +1046,161 @@ public class DevicePane extends TitledDescriptionPane {
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() {
return device;
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES;
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, GET_PRIVATE_KEY, GET_ADDRESS;
}
}
@@ -2,17 +2,24 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class DeviceSignDialog extends DeviceDialog<PSBT> {
private static final Logger log = LoggerFactory.getLogger(DeviceSignDialog.class);
private final Wallet wallet;
private final PSBT psbt;
public DeviceSignDialog(List<String> operationFingerprints, PSBT psbt) {
public DeviceSignDialog(Wallet wallet, List<String> operationFingerprints, PSBT psbt) {
super(operationFingerprints);
this.wallet = wallet;
this.psbt = psbt;
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -23,7 +30,7 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(psbt, device, defaultDevice);
return new DevicePane(wallet, psbt, device, defaultDevice);
}
@Subscribe
@@ -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) {}
}
@@ -0,0 +1,750 @@
package com.sparrowwallet.sparrow.control;
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.controlsfx.tools.Platform;
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", "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_SIGNATURE_SUFFIX = "-manifest.txt.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 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 initialSignatureFile) {
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) -> {
verify();
});
if(initialSignatureFile != null) {
javafx.application.Platform.runLater(() -> signature.set(initialSignatureFile));
}
}
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())) {
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;
}
}
if(providedFile.getName().toLowerCase(Locale.ROOT).startsWith(SPARROW_RELEASE_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) {
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() {
Platform platform = Platform.getCurrent();
switch(platform) {
case OSX -> {
return MACOS_RELEASE_EXTENSIONS;
}
case WINDOWS -> {
return WINDOWS_RELEASE_EXTENSIONS;
}
default -> {
return LINUX_RELEASE_EXTENSIONS;
}
}
}
private String getReleaseFileExample(String version) {
Platform platform = Platform.getCurrent();
String arch = System.getProperty("os.arch");
switch(platform) {
case OSX -> {
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) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
}
return false;
}
public void setSignatureFile(File signatureFile) {
signature.set(signatureFile);
}
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 StackPane();
graphicContainer.getStyleClass().add("graphic-container");
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);
graphicContainer.getChildren().add(imageView);
}
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 { }
}
@@ -12,6 +12,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
@@ -30,14 +32,16 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
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);
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 IntegerProperty confirmationsProperty;
public EntryCell() {
super();
setAlignment(Pos.CENTER_LEFT);
@@ -96,7 +100,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() &&
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
@@ -120,7 +124,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
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.setShowDelay(Duration.millis(250));
tooltip.setText(nodeEntry.getNode().toString());
@@ -145,6 +149,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(getTreeTableView().getScene().getWindow());
messageSignDialog.showAndWait();
});
actionBox.getChildren().add(signMessageButton);
@@ -189,6 +194,21 @@ 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) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
@@ -197,7 +217,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(TransactionInput::isReplaceByFeeEnabled)
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled())
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
@@ -211,31 +231,47 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.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());
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.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());
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
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);
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
//Remove any UTXOs created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
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;
if(changeTotal == 0) {
//Add change output length to vSize if change was not present on the original transaction
TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getOutputScript());
vSize += changeOutput.getLength();
}
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) {
//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());
externalOutputs.removeAll(ourOutputs);
@@ -244,17 +280,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
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()) {
String base = matcher.group(1);
if(matcher.groupCount() > 2 && matcher.group(3) != null) {
int count = Integer.parseInt(matcher.group(3)) + 1;
label = base + "(Replaced By Fee #" + count + ")";
if(matcher.groupCount() > 3 && matcher.group(4) != null) {
int count = Integer.parseInt(matcher.group(4)) + 1;
label += " (Replaced By Fee #" + count + ")";
} else {
label = base + "(Replaced By Fee #2)";
label += " (Replaced By Fee #2)";
}
} else {
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
label += " (Replaced By Fee)";
}
if(txOutput.getScript().getToAddress() != null) {
@@ -296,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
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)));
}
private static Double getMaxFeeRate() {
@@ -312,30 +352,57 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.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)
.collect(Collectors.toList());
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();
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)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false)));
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
}
private static boolean canRBF(BlockTransaction blockTransaction) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
}
private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet();
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB) &&
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
@@ -408,7 +475,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
}
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled");
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled");
}
return tooltip;
@@ -485,7 +552,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
@@ -496,7 +563,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(increaseFee);
}
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> {
@@ -518,6 +585,15 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
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");
copyTxid.setOnAction(AE -> {
hide();
@@ -530,7 +606,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) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -538,6 +614,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
hide();
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");
copyDate.setOnAction(AE -> {
@@ -546,6 +632,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
content.putString(date);
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyDate);
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
@@ -554,6 +641,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyTxid);
MenuItem copyHeight = new MenuItem("Copy Block Height");
copyHeight.setOnAction(AE -> {
@@ -562,13 +650,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
content.putString(blockTransaction.getHeight() > 0 ? Integer.toString(blockTransaction.getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(viewTransaction, copyDate, copyTxid, copyHeight);
getItems().add(copyHeight);
}
}
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()) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
@@ -586,6 +673,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(treetable.getScene().getWindow());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
@@ -635,14 +723,6 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
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");
copyOutputDescriptor.setOnAction(AE -> {
hide();
@@ -651,11 +731,23 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
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) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -711,6 +803,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
cell.getStyleClass().remove("transaction-row");
cell.getStyleClass().remove("node-row");
cell.getStyleClass().remove("utxo-row");
cell.getStyleClass().remove("unconfirmed-row");
cell.getStyleClass().remove("summary-row");
cell.getStyleClass().remove("address-cell");
cell.getStyleClass().remove("hashindex-row");
cell.getStyleClass().remove("confirming");
@@ -722,13 +816,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(entry instanceof TransactionEntry) {
cell.getStyleClass().add("transaction-row");
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
if(!transactionEntry.isConfirming()) {
cell.getStyleClass().remove("confirming");
}
});
if(cell instanceof ConfirmationsListener confirmationsListener) {
if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming");
confirmationsListener.getConfirmationsProperty().bind(transactionEntry.confirmationsProperty());
} else {
confirmationsListener.getConfirmationsProperty().unbind();
}
}
} else if(entry instanceof NodeEntry) {
cell.getStyleClass().add("node-row");
@@ -744,6 +839,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(hashIndexEntry.isSpent()) {
cell.getStyleClass().add("spent");
}
} else if(entry instanceof WalletSummaryDialog.UnconfirmedEntry) {
cell.getStyleClass().add("unconfirmed-row");
} else if(entry instanceof WalletSummaryDialog.SummaryEntry) {
cell.getStyleClass().add("summary-row");
}
}
}
@@ -0,0 +1,146 @@
package com.sparrowwallet.sparrow.control;
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.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;
public FeeRangeSlider() {
super(0, FEE_RATES_RANGE.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) {
Long feeRate = LONG_FEE_RATES_RANGE.get(object.intValue());
if(isLongFeeRange() && feeRate >= 1000) {
return feeRate / 1000 + "k";
}
return Long.toString(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 < LONG_FEE_RATES_RANGE.get(0)) {
newFeeRate = LONG_FEE_RATES_RANGE.get(0);
} else if(newFeeRate > LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1)) {
newFeeRate = LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1);
}
setFeeRate(newFeeRate);
}
});
}
public double getFeeRate() {
return Math.pow(2.0, getValue());
}
public void setFeeRate(double feeRate) {
double value = Math.log(feeRate) / Math.log(2);
updateMaxFeeRange(value);
setValue(value);
}
private void updateMaxFeeRange(double value) {
if(value >= getMax() && !isLongFeeRange()) {
setMax(LONG_FEE_RATES_RANGE.size() - 1);
updateTrackHighlight();
} else if(value == getMin() && isLongFeeRange()) {
setMax(FEE_RATES_RANGE.size() - 1);
updateTrackHighlight();
}
}
private boolean isLongFeeRange() {
return getMax() > FEE_RATES_RANGE.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 = Math.log(feeRate) / Math.log(2);
if(isLongFeeRange()) {
index *= ((double)FEE_RATES_RANGE.size() / (LONG_FEE_RATES_RANGE.size())) * 0.99;
}
return (int)Math.round(index * 10.0);
}
}
@@ -0,0 +1,94 @@
package com.sparrowwallet.sparrow.control;
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 org.controlsfx.tools.Platform;
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(Platform.getCurrent() == Platform.OSX) {
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);
}
}
}
@@ -150,6 +150,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private void importQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(this.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -162,8 +163,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
setError("Import Error", e.getMessage());
}
} else if(result.outputDescriptor != null) {
wallets = List.of(result.outputDescriptor.toKeystoreWallet(null));
try {
wallets = List.of(result.outputDescriptor.toWallet());
importFile(importer.getName(), null, null);
} catch(ImportException 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 {
if(wallets != null) {
for(Wallet wallet : wallets) {
@@ -215,7 +220,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private Node getPasswordEntry(File file) {
CustomPasswordField passwordField = new ViewPasswordField();
passwordField.setPromptText("Wallet password");
passwordField.setPromptText("Password");
password.bind(passwordField.textProperty());
HBox.setHgrow(passwordField, Priority.ALWAYS);
@@ -235,6 +240,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
javafx.application.Platform.runLater(passwordField::requestFocus);
return contentBox;
}
}
@@ -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(), "image/" + exporter.getWalletModel().getType() + ".png");
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);
}
}
}
@@ -29,22 +29,9 @@ public class FileKeystoreImportPane extends FileImportPane {
}
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 {
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;
}
}
@@ -1,7 +1,11 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.SecureString;
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.sparrow.AppServices;
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.glyphfont.FontAwesome5;
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.scene.control.Button;
import javafx.scene.control.Control;
@@ -24,16 +32,20 @@ import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Optional;
import static com.sparrowwallet.sparrow.wallet.SettingsController.getCryptoOutput;
public class FileWalletExportPane extends TitledDescriptionPane {
private final Wallet wallet;
private final WalletExport exporter;
private final boolean scannable;
private final boolean file;
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(), "image/" + exporter.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.exporter = exporter;
this.scannable = exporter.isWalletExportScannable();
this.file = exporter.isWalletExportFile();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
@@ -41,7 +53,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
@Override
protected Control createButton() {
if(scannable) {
if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
@@ -61,6 +73,15 @@ public class FileWalletExportPane extends TitledDescriptionPane {
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);
@@ -81,9 +102,11 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
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()) {
fileName = wallet.getMasterName();
fileName = wallet.getMasterName() + postfix;
}
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
@@ -98,19 +121,16 @@ public class FileWalletExportPane extends TitledDescriptionPane {
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(buttonBox.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
String walletPassword = password.get().asString();
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
try {
exportWallet(file, decryptedWallet);
} finally {
decryptedWallet.clearPrivate();
}
exportWallet(file, decryptedWallet, walletPassword);
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
@@ -120,28 +140,46 @@ public class FileWalletExportPane extends TitledDescriptionPane {
decryptWalletService.start();
}
} 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 {
if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(exportWallet, outputStream);
FileWalletExportService fileWalletExportService = new FileWalletExportService(exporter, file, exportWallet, password);
fileWalletExportService.setOnSucceeded(event -> {
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 {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(exportWallet, outputStream);
exporter.exportWallet(exportWallet, outputStream, password);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
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);
} else if(exporter instanceof Bip129) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false);
} else if(exporter instanceof Descriptor) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR());
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
@@ -150,6 +188,42 @@ public class FileWalletExportPane extends TitledDescriptionPane {
errorMessage = e.getCause().getMessage();
}
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;
}
};
}
}
}
@@ -12,13 +12,19 @@ public class FileWalletImportPane extends FileImportPane {
private final 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(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), importer.isWalletImportFileFormatAvailable());
this.importer = importer;
}
@Override
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) {
wallet.setName(fileName);
}
@@ -12,6 +12,7 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreFileImport;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -38,6 +39,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
private final KeystoreFileImport importer;
private String fileName;
private byte[] fileBytes;
private String password;
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
@@ -46,6 +48,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
this.fileName = fileName;
this.password = password;
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
if(wallets != null && !wallets.isEmpty()) {
@@ -83,7 +86,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
EventManager.get().post(new WalletImportEvent(wallet));
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
Keystore keystore = importer.getKeystore(scriptType, bais, password);
Wallet wallet = new Wallet();
wallet.setName(Files.getNameWithoutExtension(fileName));
@@ -151,6 +154,8 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
Platform.runLater(scriptTypeComboBox::requestFocus);
return contentBox;
}
}
@@ -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());
}
}
@@ -1,20 +1,23 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
public class KeystorePassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase;
private final ObjectProperty<byte[]> masterFingerprint = new SimpleObjectProperty<>();
public KeystorePassphraseDialog(Keystore keystore) {
this(null, keystore);
@@ -45,10 +48,38 @@ public class KeystorePassphraseDialog extends Dialog<String> {
content.setPrefHeight(50);
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);
warnGlyph.getStyleClass().add("warn-icon");
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);
content.getChildren().add(warnLabel);
@@ -57,4 +88,14 @@ public class KeystorePassphraseDialog extends Dialog<String> {
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);
}
}
}
@@ -1,6 +1,10 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.event.Event;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTreeTableCell;
@@ -13,9 +17,11 @@ import org.slf4j.LoggerFactory;
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 IntegerProperty confirmationsProperty;
public LabelCell() {
super(new DefaultStringConverter());
getStyleClass().add("label-cell");
@@ -60,6 +66,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
super.commitEdit(label);
Platform.runLater(() -> getTreeTableView().requestFocus());
}
@Override
@@ -81,6 +88,21 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
}
@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 class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label");
@@ -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));
}
}
@@ -1,21 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.collections.FXCollections;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.*;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
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.StringConverter;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat;
@@ -27,16 +38,100 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
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 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 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(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.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) {
super(xAxis, yAxis);
setOnMouseClicked(expandedChartHandler);
}
public void initialize() {
getStyleClass().add("vsizeChart");
setCreateSymbols(false);
setCursor(Cursor.CROSSHAIR);
setVerticalGridLinesVisible(false);
@@ -78,17 +173,18 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
}
});
long previousFeeRate = 0;
for(Long feeRate : AppServices.FEE_RATES_RANGE) {
for(int i = 0; i < FEE_RATES_INTERVALS.size(); i++) {
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<>();
series.setName(feeRate + "+ sats/vB");
series.setName(feeRate + "-" + (nextFeeRate == Integer.MAX_VALUE ? 900 : nextFeeRate));
long seriesTotalVSize = 0;
for(Date date : periodRateSizes.keySet()) {
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
long totalVSize = 0;
for(MempoolRateSize rateSize : rateSizes) {
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) {
if(rateSize.getFee() >= feeRate && rateSize.getFee() < nextFeeRate) {
totalVSize += rateSize.getVSize();
}
}
@@ -100,8 +196,19 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
if(seriesTotalVSize > 0) {
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());
@@ -131,6 +238,10 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
numberAxis.setTickLabelsVisible(false);
numberAxis.setOpacity(0);
}
if(expandedChart != null) {
expandedChart.update(mempoolRateSizes);
}
}
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
@@ -138,7 +249,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
return mempoolRateSizes;
}
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
LocalDateTime period = LocalDateTime.now().minusHours(maxPeriodHours);
return mempoolRateSizes.entrySet().stream().filter(entry -> {
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return dateTime.isAfter(period);
@@ -200,11 +311,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
double mvb = kvb / 1000;
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");
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);
if(i < 8) {
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
}
circle.setStyle("-fx-text-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.7;");
label.setGraphic(circle);
getChildren().add(label);
}
@@ -5,19 +5,16 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.Bip322;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.PolicyType;
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.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.scene.control.*;
@@ -25,6 +22,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
@@ -40,6 +38,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
@@ -49,9 +49,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private final ToggleGroup formatGroup;
private final ToggleButton formatTrezor;
private final ToggleButton formatElectrum;
private final ToggleButton formatBip322;
private final Wallet wallet;
private WalletNode walletNode;
private boolean electrumSignatureFormat;
private boolean canSign;
private boolean closed;
/**
@@ -92,10 +93,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
if(walletNode != null) {
checkWalletSigning(walletNode.getWallet());
this.canSign = canSign(walletNode.getWallet());
}
if(wallet != null) {
checkWalletSigning(wallet);
this.canSign = canSign(wallet);
}
this.wallet = wallet;
@@ -128,7 +131,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address = new TextField();
address.getStyleClass().add("id");
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);
if(walletNode != null) {
@@ -139,17 +142,18 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
messageField.setText("Message:");
message = new TextArea();
message.setWrapText(true);
message.setPrefRowCount(10);
message.setStyle("-fx-pref-height: 180px");
message.setPrefRowCount(8);
message.setStyle("-fx-pref-height: 160px");
messageField.getInputs().add(message);
Field signatureField = new Field();
signatureField.setText("Signature:");
signature = new TextArea();
signature.getStyleClass().add("id");
signature.setPrefRowCount(2);
signature.setStyle("-fx-pref-height: 60px");
signature.setPrefRowCount(4);
signature.setStyle("-fx-pref-height: 80px");
signature.setWrapText(true);
signature.setOnMouseClicked(event -> signature.selectAll());
signatureField.getInputs().add(signature);
Field formatField = new Field();
@@ -157,16 +161,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
formatGroup = new ToggleGroup();
formatElectrum = new ToggleButton("Standard (Electrum)");
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);
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);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
@@ -179,6 +178,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
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 verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -197,10 +197,20 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
}
} else {
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
dialogPane.getButtonTypes().addAll(showQrButtonType, signButtonType, verifyButtonType, doneButtonType);
Button showQrButton = (Button) dialogPane.lookupButton(showQrButtonType);
showQrButton.setDisable(wallet == null);
showQrButton.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE)));
showQrButton.setGraphicTextGap(5);
showQrButton.setOnAction(event -> {
showQr();
});
Button signButton = (Button) dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setDisable(!canSign);
signButton.setGraphic(getGlyph(getSignGlyph()));
signButton.setGraphicTextGap(5);
signButton.setOnAction(event -> {
signMessage();
});
@@ -212,7 +222,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
showQrButton.setDisable(!validAddress || (wallet == null));
signButton.setDisable(!validAddress || !canSign);
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
@@ -223,13 +234,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
showQrButton.setDisable(!valid || (wallet == null));
signButton.setDisable(!valid || !canSign);
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
if(valid) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
setFormatFromScriptType(address.getScriptType());
if(wallet != null) {
setWalletNodeFromAddress(wallet, address);
}
} catch(InvalidAddressException e) {
//can't happen
}
@@ -252,7 +267,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(ButtonBar.ButtonData.CANCEL_CLOSE));
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
setResultConverter(dialogButton -> dialogButton == showQrButtonType || dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
Platform.runLater(() -> {
if(address.getText().isEmpty()) {
@@ -261,7 +276,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
message.requestFocus();
}
formatGroup.selectToggle(formatElectrum);
if(wallet != null && walletNode != null) {
setFormatFromScriptType(wallet.getScriptType());
} else {
formatGroup.selectToggle(formatElectrum);
}
});
}
@@ -269,9 +288,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(wallet.getKeystores().size() != 1) {
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().get(0).hasPrivateKey()
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().get(0).getWalletModel().isCard();
}
private Address getAddress()throws InvalidAddressException {
@@ -282,20 +304,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
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() {
try {
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) {
return false;
}
@@ -305,12 +317,36 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address);
}
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() {
if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
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
Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
@@ -319,8 +355,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} else {
signUnencryptedKeystore(signingWallet);
}
} else if(signingWallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(signingWallet);
} else if(signingWallet.containsSource(KeystoreSource.HW_USB) || wallet.getKeystores().get(0).getWalletModel().isCard()) {
signDeviceKeystore(signingWallet);
}
}
@@ -328,8 +364,14 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
try {
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();
String signatureText = privKey.signMessage(message.getText().trim(), scriptType);
String signatureText;
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.appendText(signatureText);
privKey.clear();
@@ -339,10 +381,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
private void signUsbKeystore(Wallet usbWallet) {
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation);
private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
signature.clear();
@@ -356,16 +399,33 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
boolean verified = false;
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);
if(verified) {
formatGroup.selectToggle(formatElectrum);
}
} catch(SignatureException e) {
//ignore
}
if(!verified) {
try {
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), false);
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) {
//ignore
}
@@ -398,6 +458,60 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
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.");
}
}
}
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
public void openWallets(OpenWalletsEvent event) {
Storage storage = event.getStorage(wallet);
@@ -407,6 +521,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(getDialogPane().getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get());
@@ -418,7 +533,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
decryptWalletService.setOnFailed(workerStateEvent -> {
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..."));
decryptWalletService.start();
@@ -70,7 +70,7 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
}
private void setMixFail(MixFailReason mixFailReason, String mixError, Long mixErrorTimestamp) {
if(mixFailReason != MixFailReason.CANCEL) {
if(mixFailReason.isError()) {
long elapsed = mixErrorTimestamp == null ? 0L : System.currentTimeMillis() - mixErrorTimestamp;
if(elapsed >= ERROR_DISPLAY_MILLIS) {
//Old error, don't set again.
@@ -116,22 +116,15 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
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);
String status = mixProgress.getMixStep().getMessage().replaceAll("_", " ");
status = status.substring(0, 1).toUpperCase(Locale.ROOT) + status.substring(1).toLowerCase(Locale.ROOT);
if(mixProgress.getMixStep() == MixStep.REGISTER_INPUT) {
status += "\n\nThis progress is normal for one mixing UTXO per pool while waiting to be randomly selected for a mix.\n" +
"This may take hours or days, and time in the pool is generally more important than individual number of mixes.\n" +
"Each UTXO's anonymity set is dependent not only on its own mix count, but that of its peers as well.";
}
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);
@@ -0,0 +1,429 @@
package com.sparrowwallet.sparrow.control;
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!");
javafx.scene.image.Image image = new Image("/image/border-wallets.png");
dialogPane.setGraphic(new ImageView(image));
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", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.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;
}
}
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.SimpleListProperty;
@@ -24,7 +23,8 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
showWordList(keystore.getSeed());
}
protected Node getMnemonicWordsEntry(int numWords) {
@Override
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -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", "", "image/" + WalletModel.SEED.getType() + ".png");
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;
}
}
@@ -33,6 +33,7 @@ import java.util.Optional;
public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
protected final Wallet wallet;
private final KeystoreMnemonicImport importer;
private final KeyDerivation defaultDerivation;
private SplitMenuButton importButton;
@@ -43,10 +44,11 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private Button confirmButton;
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");
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton();
buttonBox.getChildren().add(importButton);
@@ -59,7 +61,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> {
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"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
@@ -77,6 +79,10 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
protected void enterMnemonic(int numWords) {
generatedMnemonicCode = null;
super.enterMnemonic(numWords);
@@ -223,7 +229,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private void confirmBackup() {
setDescription("Confirm backup by re-entering words");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size()));
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), true, false));
setExpanded(true);
backButton.setVisible(true);
generateButton.setVisible(false);
@@ -243,7 +249,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
setDescription("Ready to import");
showHideLink.setText("Show Derivation...");
showHideLink.setVisible(false);
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
setContent(getDerivationEntry(getDefaultDerivation()));
}
}
@@ -254,6 +260,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
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");
@@ -12,6 +12,8 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
@@ -20,8 +22,8 @@ import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.*;
import javafx.util.Callback;
import javafx.util.Duration;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -44,7 +46,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected Label invalidLabel;
protected SimpleListProperty<String> wordEntriesProperty;
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty("");
protected IntegerProperty defaultWordSizeProperty;
public MnemonicKeystorePane(String title, String description, String content, String imageUrl) {
@@ -79,6 +81,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.getItems().add(item);
}
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...");
scanItem.setOnAction(event -> {
scanQR();
@@ -87,8 +95,47 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
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 {
ScheduledService<Void> service = new ScheduledService<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
return null;
}
};
}
};
service.setDelay(Duration.millis(500));
service.setOnSucceeded(event1 -> {
service.cancel();
Platform.runLater(() -> wordEntry.getEditor().requestFocus());
});
service.start();
}
}
}
}
protected void scanQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(this.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -106,7 +153,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setContent(getMnemonicWordsEntry(words.size(), true, true));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
@@ -119,11 +166,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void enterMnemonic(int numWords) {
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords));
setContent(getMnemonicWordsEntry(numWords, true, true));
setExpanded(true);
}
protected Node getMnemonicWordsEntry(int numWords) {
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -152,10 +199,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
vBox.getChildren().add(wordsPane);
PassphraseEntry passphraseEntry = new PassphraseEntry();
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
if(showPassphrase) {
PassphraseEntry passphraseEntry = new PassphraseEntry(editPassphrase);
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
}
AnchorPane buttonPane = new AnchorPane();
buttonPane.setPadding(new Insets(0, 26, 0, 10));
@@ -250,9 +299,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) {
String[] words = clipboard.getString().split("[\\n\\s]");
WordEntry entry = WordEntry.this;
for(String word : words) {
for(int i = 0; i < words.length; i++) {
String word = words[i];
if(entry.nextField != null) {
entry.nextField.requestFocus();
if(i == words.length - 2 && isValid(word)) {
label.requestFocus();
} else {
entry.nextField.requestFocus();
}
}
entry.wordField.setText(word);
@@ -282,7 +336,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
wordField.setTextFormatter(formatter);
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList, wordNumber, wordEntryList));
autoCompletionBinding.setDelay(50);
autoCompletionBinding.setOnAutoCompleted(event -> {
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.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(wordField, Validator.combine(
Validator.createEmptyValidator("Word is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", (newValue.length() > 0 || !lastWord) && !wordList.contains(newValue))
));
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
@@ -323,13 +386,30 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> {
private final List<String> wordList;
private final int wordNumber;
private final ObservableList<String> wordEntryList;
public WordlistSuggestionProvider(List<String> wordList) {
public WordlistSuggestionProvider(List<String> wordList, int wordNumber, ObservableList<String> wordEntryList) {
this.wordList = wordList;
this.wordNumber = wordNumber;
this.wordEntryList = wordEntryList;
}
@Override
public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) {
if(wordNumber == wordEntryList.size() - 1 && allPreviousWordsValid()) {
try {
List<String> possibleLastWords = Bip39MnemonicCode.INSTANCE.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<>();
if(!request.getUserText().isEmpty()) {
for(String word : wordList) {
@@ -341,26 +421,63 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
return suggestions;
}
private boolean allPreviousWordsValid() {
for(int i = 0; i < wordEntryList.size() - 1; i++) {
if(!WordEntry.isValid(wordEntryList.get(i))) {
return false;
}
}
return true;
}
}
protected class PassphraseEntry extends HBox {
private final CustomTextField passphraseField;
private final TextField passphraseField;
public PassphraseEntry() {
public PassphraseEntry(boolean editable) {
super();
setAlignment(Pos.CENTER_LEFT);
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:");
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());
passphraseField.setPromptText("Leave blank for none");
HelpLabel helpLabel = new HelpLabel();
helpLabel.setPrefHeight(28);
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() {
@@ -10,9 +10,11 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreMnemonicImport;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -107,6 +109,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
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(ScriptType.P2PKH.getDefaultDerivation(1)); //Bisq segwit misderivation
for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE)) {
for(List<ChildNumber> derivation : derivations) {
@@ -135,7 +138,10 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
EventManager.get().post(new WalletImportEvent(optWallet.get()));
} else {
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) {
setContent(getScriptTypeEntry());
setExpanded(true);
@@ -183,7 +189,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
scriptTypeComboBox.setMaxWidth(170);
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);
Region region = new Region();
@@ -110,6 +110,7 @@ public class PayNymCell extends ListCell<PayNym> {
MenuItem rename = new MenuItem("Rename Contact...");
rename.setOnAction(event -> {
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
walletLabelDialog.initOwner(PayNymCell.this.getScene().getWindow());
Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) {
int index = getListView().getItems().indexOf(payNym);
@@ -1,5 +1,7 @@
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.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
@@ -13,8 +15,14 @@ import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.Wallet;
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.io.CardApi;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
@@ -42,9 +50,7 @@ import tornadofx.control.Form;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
@@ -57,6 +63,8 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private final CopyableLabel keyAddress;
private final ComboBoxTextField toAddress;
private final ComboBox<Wallet> toWallet;
private final FeeRangeSlider feeRange;
private final CopyableLabel feeRate;
public PrivateKeySweepDialog(Wallet wallet) {
final DialogPane dialogPane = getDialogPane();
@@ -96,6 +104,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
HBox.setHgrow(key, Priority.ALWAYS);
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();
keyScriptTypeField.setText("Script Type:");
keyScriptType = new ComboBox<>();
@@ -133,7 +149,24 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
stackPane.getChildren().addAll(toWallet, toAddress);
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);
dialogPane.setContent(form);
@@ -157,6 +190,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
boolean isValidKey = isValidKey();
createButton.setDisable(!isValidKey || !isValidToAddress());
setScriptTypes(isValidKey);
if(isValidKey) {
setFromAddress();
}
@@ -188,6 +222,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
setResultConverter(dialogButton -> null);
dialogPane.setPrefWidth(680);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
@@ -211,6 +250,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private void decryptKey() {
PassphraseDialog passphraseDialog = new PassphraseDialog();
passphraseDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optPassphrase = passphraseDialog.showAndWait();
if(optPassphrase.isPresent()) {
try {
@@ -250,8 +290,19 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
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() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
if(result.isPresent() && result.get().payload != null) {
key.setText(result.get().payload);
@@ -279,6 +330,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() {
try {
DumpedPrivateKey privateKey = getPrivateKey();
@@ -286,14 +348,32 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
Address fromAddress = scriptType.getAddress(privateKey.getKey());
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();
}
}
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress, since);
addressUtxosService.setOnSucceeded(successEvent -> {
createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress);
});
addressUtxosService.setOnFailed(failedEvent -> {
Throwable rootCause = Throwables.getRootCause(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...", "/image/sparrow.png", addressUtxosService);
serviceProgressDialog.initOwner(getDialogPane().getScene().getWindow());
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
}
addressUtxosService.start();
} catch(Exception e) {
log.error("Error creating sweep transaction", e);
@@ -313,7 +393,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
noFeeTransaction.addOutput(sweepOutput);
Double feeRate = AppServices.getDefaultFeeRate();
double feeRate = feeRange.getFeeRate();
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
fee++;
@@ -321,8 +401,13 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
feeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + 1;
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
}
}
Transaction transaction = new Transaction();
@@ -334,11 +419,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
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++) {
TransactionOutput utxoOutput = txOutputs.get(i);
TransactionInput txInput = transaction.getInputs().get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
@@ -370,6 +461,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
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> {
private final CustomPasswordField passphrase;
@@ -1,22 +1,28 @@
package com.sparrowwallet.sparrow.control;
public enum QRDensity {
NORMAL("Normal", 250),
LOW("Low", 80);
NORMAL("Normal", 400, 2000),
LOW("Low", 80, 1000);
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.maxFragmentLength = maxFragmentLength;
this.maxUrFragmentLength = maxUrFragmentLength;
this.maxBbqrFragmentLength = maxBbqrFragmentLength;
}
public String getName() {
return name;
}
public int getMaxFragmentLength() {
return maxFragmentLength;
public int getMaxUrFragmentLength() {
return maxUrFragmentLength;
}
public int getMaxBbqrFragmentLength() {
return maxBbqrFragmentLength;
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
@@ -13,6 +14,9 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR;
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.Task;
import javafx.scene.Node;
@@ -29,21 +33,30 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@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 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 QR_HEIGHT = 480;
private static final int DEFAULT_QR_SIZE = 580;
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 UREncoder encoder;
private UREncoder urEncoder;
private final BBQR bbqr;
private BBQREncoder bbqrEncoder;
private boolean useBbqrEncoding;
private final ImageView qrImageView;
@@ -58,17 +71,92 @@ public class QRDisplayDialog extends Dialog<UR> {
private static boolean initialDensityChange;
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) {
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.addLegacyEncodingOption = addLegacyEncodingOption;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
this.bbqr = bbqr;
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();
setDialogPane(dialogPane);
@@ -78,54 +166,27 @@ public class QRDisplayDialog extends Dialog<UR> {
qrImageView = new ImageView();
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());
qrImageView.setImage(getQrCode(data));
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);
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);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
setResultConverter(dialogButton -> dialogButton);
}
private int getQRSize() {
return AppServices.isReducedWindowHeight() ? REDUCED_QR_SIZE : DEFAULT_QR_SIZE;
}
private void createAnimateQRService() {
@@ -137,9 +198,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() {
if(!useLegacyEncoding) {
String fragment = encoder.nextPart();
if(useBbqrEncoding) {
String fragment = bbqrEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else if(!useLegacyEncoding) {
String fragment = urEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else {
currentPart = legacyParts[legacyPartIndex];
@@ -153,7 +227,7 @@ public class QRDisplayDialog extends Dialog<UR> {
protected Image getQrCode(String fragment) {
try {
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();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
@@ -175,37 +249,23 @@ public class QRDisplayDialog extends Dialog<UR> {
this.legacyParts = legacyEncoder.encode();
this.useLegacyEncoding = true;
if(legacyParts.length == 1) {
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
restartAnimation();
} catch(UR.InvalidTypeException e) {
//Can't happen
}
} else {
this.useLegacyEncoding = false;
restartAnimation();
}
}
if(encoder.isSinglePart()) {
if(animateQRService != null) {
animateQRService.cancel();
}
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
private void setUseBbqrEncoding(boolean useBbqrEncoding) {
if(useBbqrEncoding) {
this.useBbqrEncoding = true;
restartAnimation();
} else {
this.useBbqrEncoding = false;
restartAnimation();
}
}
@@ -214,12 +274,28 @@ public class QRDisplayDialog extends Dialog<UR> {
animateQRService.cancel();
}
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
nextPart();
if(encoder.isSinglePart()) {
if(bbqr != null) {
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
}
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));
} else {
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
}
@@ -264,7 +340,7 @@ public class QRDisplayDialog extends Dialog<UR> {
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(density, buttonData);
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);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
initialDensityChange = true;
@@ -280,6 +356,25 @@ public class QRDisplayDialog extends Dialog<UR> {
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);
@@ -16,6 +16,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -24,9 +25,14 @@ import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR;
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.glyphfont.FontAwesome5;
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.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
@@ -53,14 +59,16 @@ import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@SuppressWarnings("deprecation")
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
private final URDecoder decoder;
private final LegacyURDecoder legacyDecoder;
private final URDecoder urDecoder;
private final LegacyURDecoder legacyUrDecoder;
private final BBQRDecoder bbqrDecoder;
private final WebcamService webcamService;
private List<String> parts;
@@ -76,8 +84,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
public QRScanDialog() {
this.decoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder();
this.urDecoder = new URDecoder();
this.legacyUrDecoder = new LegacyURDecoder();
this.bbqrDecoder = new BBQRDecoder();
if(Config.get().isHdCapture()) {
webcamResolutionProperty.set(WebcamResolution.HD);
@@ -163,12 +172,13 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
Platform.runLater(() -> webcamResolutionProperty.set(null));
});
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 camButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
@@ -187,30 +197,44 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) {
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete()));
legacyUrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyUrDecoder.getPercentComplete()));
if(legacyDecoder.isComplete()) {
if(legacyUrDecoder.isComplete()) {
try {
UR ur = legacyDecoder.decode();
UR ur = legacyUrDecoder.decode();
result = extractResultFromUR(ur);
} catch(Exception e) {
result = new Result(new URException(e.getMessage()));
}
}
} else {
decoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0));
urDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(urDecoder.getProcessedPartsCount() > 0 ? urDecoder.getEstimatedPercentComplete() : 0));
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
if(urDecoder.getResult() != null) {
URDecoder.Result urResult = urDecoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result));
} else {
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()) {
int m = Integer.parseInt(partMatcher.group(1));
int n = Integer.parseInt(partMatcher.group(2));
@@ -259,7 +283,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
DeterministicSeed seed;
try {
extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey);
result = new Result(extendedKey, null);
return;
} catch(Exception e) {
//Ignore, not a valid xpub
@@ -350,6 +374,17 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//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);
}
}
@@ -408,7 +443,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
return new Result(extendedKey);
return new Result(extendedKey, cryptoHDKey.getName());
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
@@ -425,6 +460,39 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
CryptoBip39 cryptoBip39 = (CryptoBip39)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoBip39);
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 {
log.error("Unsupported UR type " + urRegistryType);
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
@@ -466,7 +534,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoHDKey.getOrigin() != null) {
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
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();
}
if(cryptoHDKey.getParentFingerprint() != null) {
@@ -484,18 +554,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoOutput.getMultiKey() != null) {
MultiKey multiKey = cryptoOutput.getMultiKey();
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
Map<ExtendedKey, String> extendedPublicKeyLabels = new LinkedHashMap<>();
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
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) {
throw new IllegalArgumentException("EC keys are currently unsupported");
} else if(cryptoOutput.getHdKey() != null) {
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
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");
@@ -542,7 +616,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
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;
@@ -555,6 +636,53 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private DeterministicSeed getSeed(CryptoBip39 cryptoBip39) {
return new DeterministicSeed(cryptoBip39.getWords(), null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
}
private OutputDescriptor getOutputDescriptor(UROutputDescriptor urOutputDescriptor) {
String source = urOutputDescriptor.getSource();
List<RegistryItem> keys = urOutputDescriptor.getKeys();
Map<ExtendedKey, String> mapExtendedPublicKeyLabels = new LinkedHashMap<>();
for(int i = 0; i < keys.size(); i++) {
RegistryItem key = keys.get(i);
if(key instanceof URHDKey urhdKey) {
ExtendedKey extendedKey = getExtendedKey(urhdKey);
KeyDerivation keyDerivation = getKeyDerivation(urhdKey.getOrigin());
source = source.replaceAll("@" + i, OutputDescriptor.writeKey(extendedKey, keyDerivation, null, true, true));
if(urhdKey.getName() != null) {
mapExtendedPublicKeyLabels.put(extendedKey, urhdKey.getName());
}
} else {
throw new IllegalArgumentException("Only extended HD keys are supported in output descriptors");
}
}
return OutputDescriptor.getOutputDescriptor(source, mapExtendedPublicKeyLabels);
}
private List<Wallet> getWallets(URAccountDescriptor urAccountDescriptor) {
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;
}
private Result extractResultFromBBQR(BBQRDecoder.Result result) {
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 QRScanListener implements WebcamListener {
@@ -656,6 +784,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final PSBT psbt;
public final BitcoinURI uri;
public final ExtendedKey extendedKey;
public final String extendedKeyName;
public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets;
public final DeterministicSeed seed;
@@ -667,6 +796,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -679,6 +809,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = psbt;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -691,6 +822,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = uri;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -703,6 +835,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -710,11 +843,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.exception = null;
}
public Result(ExtendedKey extendedKey) {
public Result(ExtendedKey extendedKey, String name) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = extendedKey;
this.extendedKeyName = name;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -727,6 +861,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = outputDescriptor;
this.wallets = null;
this.seed = null;
@@ -739,6 +874,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = wallets;
this.seed = null;
@@ -751,6 +887,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = seed;
@@ -763,6 +900,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -775,6 +913,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -54,10 +54,14 @@ public class ScriptArea extends CodeArea {
ScriptChunk chunk = script.getChunks().get(i);
if(chunk.isOpCode()) {
append(chunk.toString(), "script-opcode");
} else if(chunk.isSignature()) {
append("<signature" + signatureCount++ + ">", "script-signature");
} else if(chunk.isPubKey()) {
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()) {
Script nestedScript = chunk.getScript();
if (nestedScript.equals(redeemScript)) {
@@ -2,25 +2,22 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.sparrow.BaseController;
import javafx.geometry.Point2D;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.ContextMenuEvent;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.model.TwoDimensional;
import java.util.OptionalInt;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
public class ScriptContextMenu extends ContextMenu {
private Script script;
private MenuItem copyvalue;
private ScriptChunk hoverChunk;
public ScriptContextMenu(CodeArea area, Script script)
public ScriptContextMenu(ScriptArea area, Script script)
{
this.script = script;
@@ -40,12 +37,9 @@ public class ScriptContextMenu extends ContextMenu {
Point2D point = area.screenToLocal(event.getScreenX(), event.getScreenY());
OptionalInt characterIndex = area.hit(point.getX(), point.getY()).getCharacterIndex();
if(characterIndex.isPresent()) {
TwoDimensional.Position position = area.getParagraph(0).getStyleSpans().offsetToPosition(characterIndex.getAsInt(), Backward);
if(position.getMajor() % 2 == 0) {
ScriptChunk chunk = script.getChunks().get(position.getMajor() / 2);
if(!chunk.isOpCode()) {
this.hoverChunk = chunk;
}
ScriptChunk chunk = BaseController.getScriptChunk(area, characterIndex.getAsInt());
if(chunk != null) {
this.hoverChunk = chunk;
}
}
copyvalue.setDisable(hoverChunk == null);
@@ -1,16 +1,27 @@
package com.sparrowwallet.sparrow.control;
import com.csvreader.CsvWriter;
import com.sparrowwallet.drongo.BitcoinUnit;
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.Wallet;
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 javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.textfield.TextFields;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -18,9 +29,11 @@ import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class SearchWalletDialog extends Dialog<Entry> {
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
@@ -36,13 +49,16 @@ public class SearchWalletDialog extends Dialog<Entry> {
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();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.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("search.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Search Wallet");
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet " + walletForms.get(0).getMasterWallet().getName());
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if(!image.isError()) {
@@ -69,23 +85,30 @@ public class SearchWalletDialog extends Dialog<Entry> {
fieldset.getChildren().addAll(searchField);
form.getChildren().add(fieldset);
boolean showWallet = walletForms.size() > 1 || walletForms.stream().anyMatch(walletForm -> !walletForm.getNestedWalletForms().isEmpty());
results = new CoinTreeTable();
results.setShowRoot(false);
results.setPrefWidth(showWallet ? 950 : 850);
results.setPrefWidth(showWallet || showAccount ? 950 : 850);
results.setUnitFormat(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results"));
results.setEditable(true);
if(showWallet) {
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
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);
}
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");
typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType());
@@ -105,7 +128,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return param.getValue().getValue().labelProperty();
});
labelCol.setCellFactory(p -> new SearchLabelCell());
labelCol.setCellFactory(p -> new LabelCell());
results.getColumns().add(labelCol);
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
@@ -118,12 +141,20 @@ public class SearchWalletDialog extends Dialog<Entry> {
vBox.getChildren().addAll(form, results);
dialogPane.setContent(vBox);
ButtonType exportButtonType = new ButtonType("Export CSV", ButtonBar.ButtonData.LEFT);
ButtonType showButtonType = new javafx.scene.control.ButtonType("Show", ButtonBar.ButtonData.APPLY);
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.setDisable(true);
@@ -135,54 +166,51 @@ public class SearchWalletDialog extends Dialog<Entry> {
});
search.textProperty().addListener((observable, oldValue, newValue) -> {
searchWallet(newValue.toLowerCase(Locale.ROOT));
searchWallets(newValue);
});
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(search::requestFocus);
}
private void searchWallet(String searchText) {
List<Entry> matchingEntries = new ArrayList<>();
public List<WalletForm> getWalletForms() {
return walletForms;
}
if(!searchText.isEmpty()) {
Long searchValue = null;
private void searchWallets(String searchPhrase) {
Set<Entry> matchingEntries = new LinkedHashSet<>();
try {
searchValue = Math.abs(Long.parseLong(searchText));
} catch(NumberFormatException e) {
//ignore
if(!searchPhrase.isEmpty()) {
Set<String> searchWords = new LinkedHashSet<>(Arrays.stream(searchPhrase.split("\\s+"))
.filter(text -> isAddress(text) || isHash(text) || isHashIndex(text)).toList());
String freeText = removeOccurrences(searchPhrase, searchWords).trim();
if(!freeText.isEmpty()) {
searchWords.add(freeText);
}
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
for(Entry entry : walletTransactionsEntry.getChildren()) {
if(entry instanceof TransactionEntry transactionEntry) {
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(String searchText : searchWords) {
Long searchValue = getSearchValue(searchText);
Address searchAddress = getSearchAddress(searchText);
searchText = searchText.toLowerCase(Locale.ROOT);
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) {
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
for(Entry entry : walletTransactionsEntry.getChildren()) {
if(entry instanceof TransactionEntry transactionEntry) {
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) ||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
matchingEntries.add(entry);
}
}
}
}
for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) {
for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) {
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
@@ -193,26 +221,147 @@ public class SearchWalletDialog extends Dialog<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.getLabel() != null && hashIndexEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(hashIndexEntry.getValue() != null && searchValue != null && Math.abs(hashIndexEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) {
for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) {
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
(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);
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 {
public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
super(wallet, wallet.getName(), entries);
@@ -238,15 +387,19 @@ public class SearchWalletDialog extends Dialog<Entry> {
@Override
protected void updateItem(Entry entry, boolean empty) {
super.updateItem(entry, empty);
setContextMenu(null);
}
}
private static class SearchLabelCell extends LabelCell {
@Override
public void updateItem(String label, boolean empty) {
super.updateItem(label, empty);
setContextMenu(null);
ContextMenu copyMenu;
if(entry instanceof TransactionEntry transactionEntry) {
copyMenu = new TransactionContextMenu(getText(), transactionEntry.getBlockTransaction());
} else if(entry instanceof NodeEntry nodeEntry) {
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);
}
}
}
@@ -1,12 +1,16 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Optional;
public class SeedDisplayDialog extends Dialog<Void> {
public SeedDisplayDialog(Keystore decryptedKeystore) {
final DialogPane dialogPane = getDialogPane();
@@ -39,8 +43,15 @@ public class SeedDisplayDialog extends Dialog<Void> {
stackPane.getChildren().addAll(anchorPane);
final ButtonType seedQRButtonType = new javafx.scene.control.ButtonType("Show SeedQR", ButtonBar.ButtonData.LEFT);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.getButtonTypes().addAll(seedQRButtonType, cancelButtonType);
Button seedQRButton = (Button)dialogPane.lookupButton(seedQRButtonType);
seedQRButton.addEventFilter(ActionEvent.ACTION, event -> {
event.consume();
showSeedQR(decryptedKeystore);
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(150 + height);
@@ -48,4 +59,15 @@ public class SeedDisplayDialog extends Dialog<Void> {
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();
}
}
}
@@ -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();
}
}
@@ -0,0 +1,34 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import org.fxmisc.richtext.CodeArea;
public class SelectableCodeArea extends CodeArea {
public SelectableCodeArea() {
super();
ContextMenu contextMenu = new ContextMenu();
MenuItem copy = new MenuItem("Copy");
copy.setDisable(true);
copy.setOnAction(event -> {
ClipboardContent content = new ClipboardContent();
content.putString(getSelectedText());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyAll = new MenuItem("Copy All");
copyAll.setOnAction(event -> {
ClipboardContent content = new ClipboardContent();
content.putString(getText());
Clipboard.getSystemClipboard().setContent(content);
});
contextMenu.getItems().addAll(copy, copyAll);
setContextMenu(contextMenu);
selectedTextProperty().addListener((observable, oldValue, newValue) -> {
copy.setDisable(newValue.isEmpty());
});
}
}
@@ -81,6 +81,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
setResizable(true);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
@@ -11,10 +11,13 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.Optional;
public class TextAreaDialog extends Dialog<String> {
@@ -22,6 +25,8 @@ public class TextAreaDialog extends Dialog<String> {
private final TextArea textArea;
private final String defaultValue;
private final String fileName;
private final byte[] fileBytes;
public TextAreaDialog() {
this("");
@@ -31,7 +36,11 @@ public class TextAreaDialog extends Dialog<String> {
this(defaultValue, true);
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
public TextAreaDialog(String defaultValue, boolean editable) {
this(defaultValue, editable, null, null);
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable, @NamedArg("fileName") String fileName, @NamedArg("fileBytes") byte[] fileBytes) {
final DialogPane dialogPane = new TextAreaDialogPane();
setDialogPane(dialogPane);
@@ -48,6 +57,8 @@ public class TextAreaDialog extends Dialog<String> {
HBox.setHgrow(this.textArea, Priority.ALWAYS);
this.defaultValue = defaultValue;
this.fileName = fileName;
this.fileBytes = fileBytes;
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
@@ -60,6 +71,9 @@ public class TextAreaDialog extends Dialog<String> {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(scanButtonType);
} else if(fileBytes != null) {
final ButtonType saveButtonType = new javafx.scene.control.ButtonType("Save File...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(saveButtonType);
}
Platform.runLater(textArea::requestFocus);
@@ -71,6 +85,7 @@ public class TextAreaDialog extends Dialog<String> {
dialogPane.setPrefWidth(700);
dialogPane.setPrefHeight(400);
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
}
@@ -86,7 +101,7 @@ public class TextAreaDialog extends Dialog<String> {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Scan QR")) {
Button scanButton = new Button(buttonType.getText());
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
@@ -95,6 +110,7 @@ public class TextAreaDialog extends Dialog<String> {
ButtonBar.setButtonData(scanButton, buttonData);
scanButton.setOnAction(event -> {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -118,6 +134,38 @@ public class TextAreaDialog extends Dialog<String> {
});
button = scanButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Save File...")) {
Button saveButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(saveButton, buttonData);
saveButton.setOnAction(event -> {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save File");
if(fileName != null) {
fileChooser.setInitialFileName(fileName);
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try {
try(OutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(fileBytes);
outputStream.flush();
}
} catch(IOException e) {
log.error("Error saving file", e);
AppServices.showErrorDialog("Error saving file", "Cannot write to " + file.getAbsolutePath());
}
}
});
button = saveButton;
} else {
button = super.createButton(buttonType);
}
@@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TextfieldDialog extends Dialog<String> {
private static final Logger log = LoggerFactory.getLogger(TextAreaDialog.class);
private final TextField textField;
private final String defaultValue;
public TextfieldDialog() {
this("");
}
public TextfieldDialog(String defaultValue) {
this(defaultValue, true);
}
public TextfieldDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
final DialogPane dialogPane = getDialogPane();
setDialogPane(dialogPane);
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
HBox hbox = new HBox();
this.textField = new TextField(defaultValue);
this.textField.setMaxWidth(Double.MAX_VALUE);
this.textField.setEditable(editable);
hbox.getChildren().add(textField);
HBox.setHgrow(this.textField, Priority.ALWAYS);
this.defaultValue = defaultValue;
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getStyleClass().add("text-input-dialog");
dialogPane.getButtonTypes().add(ButtonType.OK);
if(editable) {
dialogPane.getButtonTypes().add(ButtonType.CANCEL);
}
Platform.runLater(textField::requestFocus);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? textField.getText() : null;
});
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(230);
AppServices.moveToActiveWindowScreen(this);
}
public final TextField getEditor() {
return textField;
}
public final String getDefaultValue() {
return defaultValue;
}
}
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.geometry.Insets;
@@ -106,13 +108,17 @@ public class TitledDescriptionPane extends TitledPane {
protected void setDescription(String text) {
descriptionLabel.getStyleClass().remove("description-error");
descriptionLabel.getStyleClass().add("description-label");
if(!descriptionLabel.getStyleClass().contains("description-label")) {
descriptionLabel.getStyleClass().add("description-label");
}
descriptionLabel.setText(text);
}
protected void setError(String title, String detail) {
descriptionLabel.getStyleClass().remove("description-label");
descriptionLabel.getStyleClass().add("description-error");
if(!descriptionLabel.getStyleClass().contains("description-error")) {
descriptionLabel.getStyleClass().add("description-error");
}
descriptionLabel.setText(title);
if(detail != null && !detail.isEmpty()) {
setContent(getContentBox(detail));
@@ -159,4 +165,17 @@ public class TitledDescriptionPane extends TitledPane {
}
});
}
protected 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;
}
}
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.Node;
@@ -33,15 +34,15 @@ public class TorStatusLabel extends Label {
}
public void update() {
boolean proxyInUse = AppServices.isUsingProxy();
boolean internal = AppServices.isTorRunning();
if(!proxyInUse || internal) {
if(!Config.get().isUseProxy()) {
torConnectionTest.cancel();
if(internal) {
if(AppServices.isTorRunning()) {
setTooltip(new Tooltip("Internal Tor proxy enabled"));
}
} else if(!torConnectionTest.isRunning()) {
if(torConnectionTest.getState() == Worker.State.CANCELLED || torConnectionTest.getState() == Worker.State.FAILED) {
torConnectionTest.reset();
}
torConnectionTest.setPeriod(Duration.seconds(20.0));
torConnectionTest.setBackoffStrategy(null);
torConnectionTest.setOnSucceeded(workerStateEvent -> {
@@ -14,6 +14,7 @@ import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.event.SorobanInitiatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
@@ -44,7 +45,6 @@ import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
@@ -56,6 +56,8 @@ import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.glyphfont.GlyphUtils.*;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
private static final int REDUCED_MAX_UTXOS = MAX_UTXOS - 2;
@@ -72,6 +74,7 @@ public class TransactionDiagram extends GridPane {
private WalletTransaction walletTx;
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<TransactionDiagramLabel> labelProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
private boolean expanded;
private TransactionDiagram expandedDiagram;
@@ -88,7 +91,7 @@ public class TransactionDiagram extends GridPane {
stage.setResizable(false);
StackPane scenePane = new StackPane();
if(Platform.getCurrent() == Platform.WINDOWS) {
if(Platform.getCurrent() == Platform.WINDOWS || Platform.getCurrent() == Platform.UNIX) {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
@@ -154,6 +157,10 @@ public class TransactionDiagram extends GridPane {
updateDerivedDiagram(expandedDiagram);
}
}
if(getLabel() != null) {
getLabel().update(this);
}
}
public void update(String message) {
@@ -469,7 +476,7 @@ public class TransactionDiagram extends GridPane {
tooltip.setText("");
} else if(input instanceof AddUserBlockTransactionHashIndex) {
tooltip.setText("");
label.setGraphic(walletTx.isTwoPersonCoinjoin() ? getQuestionGlyph() : getWarningGlyph());
label.setGraphic(walletTx.isTwoPersonCoinjoin() ? getQuestionGlyph() : getFeeWarningGlyph());
label.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
closeExpanded();
@@ -534,8 +541,9 @@ public class TransactionDiagram extends GridPane {
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
}
private String getSatsValue(long amount) {
return String.format(Locale.ENGLISH, "%,d", amount);
String getSatsValue(long amount) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.formatSatsValue(amount);
}
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -636,7 +644,7 @@ public class TransactionDiagram extends GridPane {
double width = 140.0;
long sum = walletTx.getTotal();
List<Long> values = walletTx.getTransaction().getOutputs().stream().map(TransactionOutput::getValue).collect(Collectors.toList());
List<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
values.add(walletTx.getFee());
int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
for(int i = 1; i <= numOutputs; i++) {
@@ -680,18 +688,18 @@ public class TransactionDiagram extends GridPane {
List<OutputNode> outputNodes = new ArrayList<>();
for(Payment payment : displayedPayments) {
Glyph outputGlyph = getOutputGlyph(payment);
Glyph outputGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment;
payment.setLabel(getOutputLabel(payment));
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = getToWallet(payment);
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Wallet toBip47Wallet = getBip47SendWallet(payment);
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : ""));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
@@ -713,6 +721,7 @@ public class TransactionDiagram extends GridPane {
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount()));
}
Set<Integer> seenIndexes = new HashSet<>();
for(Map.Entry<WalletNode, Long> changeEntry : walletTx.getChangeMap().entrySet()) {
WalletNode changeNode = changeEntry.getKey();
WalletNode defaultChangeNode = walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE);
@@ -758,11 +767,15 @@ public class TransactionDiagram extends GridPane {
actionBox.getChildren().addAll(region, amountLabel);
}
outputNodes.add(new OutputNode(actionBox, changeAddress, changeEntry.getValue()));
}
if(isFinal()) {
Collections.sort(outputNodes);
int changeIndex = outputNodes.size();
if(isFinal()) {
changeIndex = getOutputIndex(changeAddress, changeEntry.getValue(), seenIndexes);
seenIndexes.add(changeIndex);
if(changeIndex > outputNodes.size()) {
changeIndex = outputNodes.size();
}
}
outputNodes.add(changeIndex, new OutputNode(actionBox, changeAddress, changeEntry.getValue()));
}
for(OutputNode outputNode : outputNodes) {
@@ -776,7 +789,7 @@ public class TransactionDiagram extends GridPane {
}
boolean highFee = (walletTx.getFeePercentage() > 0.1);
Label feeLabel = highFee ? new Label("High Fee", getWarningGlyph()) : new Label("Fee", getFeeGlyph());
Label feeLabel = highFee ? new Label("High Fee", getFeeWarningGlyph()) : new Label("Fee", getFeeGlyph());
feeLabel.getStyleClass().addAll("output-label", "fee-label");
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
@@ -916,38 +929,10 @@ public class TransactionDiagram extends GridPane {
return spacer;
}
private String getOutputLabel(Payment payment) {
if(payment.getLabel() != null) {
return payment.getLabel();
}
if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) {
return "Whirlpool Fee";
} else if(walletTx.isPremixSend(payment)) {
int premixIndex = getOutputIndex(payment.getAddress(), payment.getAmount()) - 2;
return "Premix #" + premixIndex;
} else if(walletTx.isBadbankSend(payment)) {
return "Badbank Change";
}
return null;
}
private int getOutputIndex(Address address, long amount) {
return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow();
}
private Wallet getToWallet(Payment payment) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet != walletTx.getWallet() && openWallet.isValid()) {
WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress());
if(addressNode != null) {
return addressNode.getWallet();
}
}
}
return null;
private int getOutputIndex(Address address, long amount, Collection<Integer> seenIndexes) {
List<TransactionOutput> addressOutputs = walletTx.getTransaction().getOutputs().stream().filter(txOutput -> txOutput.getScript().getToAddress() != null).collect(Collectors.toList());
TransactionOutput output = addressOutputs.stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex())).findFirst().orElseThrow();
return addressOutputs.indexOf(output);
}
private Wallet getBip47SendWallet(Payment payment) {
@@ -967,151 +952,6 @@ public class TransactionDiagram extends GridPane {
return null;
}
public Glyph getOutputGlyph(Payment payment) {
if(payment.getType().equals(Payment.Type.MIX)) {
return getMixGlyph();
} else if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
return getFakeMixGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
return getConsolidationGlyph();
} else if(walletTx.isPremixSend(payment)) {
return getPremixGlyph();
} else if(walletTx.isBadbankSend(payment)) {
return getBadbankGlyph();
} else if(payment.getType().equals(Payment.Type.WHIRLPOOL_FEE)) {
return getWhirlpoolFeeGlyph();
} else if(payment instanceof AdditionalPayment) {
return ((AdditionalPayment)payment).getOutputGlyph(this);
} else if(getToWallet(payment) != null) {
return getDepositGlyph();
}
return getPaymentGlyph();
}
public static Glyph getExcludeGlyph() {
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
excludeGlyph.getStyleClass().add("exclude-utxo");
excludeGlyph.setFontSize(12);
return excludeGlyph;
}
public static Glyph getPaymentGlyph() {
Glyph paymentGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
paymentGlyph.getStyleClass().add("payment-icon");
paymentGlyph.setFontSize(12);
return paymentGlyph;
}
public static Glyph getConsolidationGlyph() {
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
consolidationGlyph.getStyleClass().add("consolidation-icon");
consolidationGlyph.setFontSize(12);
return consolidationGlyph;
}
public static Glyph getDepositGlyph() {
Glyph depositGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
depositGlyph.getStyleClass().add("deposit-icon");
depositGlyph.setFontSize(12);
return depositGlyph;
}
public static Glyph getPremixGlyph() {
Glyph premixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
premixGlyph.getStyleClass().add("premix-icon");
premixGlyph.setFontSize(12);
return premixGlyph;
}
public static Glyph getBadbankGlyph() {
Glyph badbankGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BIOHAZARD);
badbankGlyph.getStyleClass().add("badbank-icon");
badbankGlyph.setFontSize(12);
return badbankGlyph;
}
public static Glyph getWhirlpoolFeeGlyph() {
Glyph whirlpoolFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_WATER);
whirlpoolFeeGlyph.getStyleClass().add("whirlpoolfee-icon");
whirlpoolFeeGlyph.setFontSize(12);
return whirlpoolFeeGlyph;
}
public static Glyph getFakeMixGlyph() {
Glyph fakeMixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.THEATER_MASKS);
fakeMixGlyph.getStyleClass().add("fakemix-icon");
fakeMixGlyph.setFontSize(12);
return fakeMixGlyph;
}
public static Glyph getTxoGlyph() {
return getChangeGlyph();
}
public static Glyph getMixGlyph() {
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
payjoinGlyph.getStyleClass().add("mix-icon");
payjoinGlyph.setFontSize(12);
return payjoinGlyph;
}
public static Glyph getChangeGlyph() {
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
changeGlyph.getStyleClass().add("change-icon");
changeGlyph.setFontSize(12);
return changeGlyph;
}
public static Glyph getChangeWarningGlyph() {
Glyph changeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
changeWarningGlyph.getStyleClass().add("change-warning-icon");
changeWarningGlyph.setFontSize(12);
return changeWarningGlyph;
}
public static Glyph getChangeReplaceGlyph() {
Glyph changeReplaceGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
changeReplaceGlyph.getStyleClass().add("change-replace-icon");
changeReplaceGlyph.setFontSize(12);
return changeReplaceGlyph;
}
private Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
feeGlyph.getStyleClass().add("fee-icon");
feeGlyph.setFontSize(12);
return feeGlyph;
}
private Glyph getWarningGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getQuestionGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QUESTION_CIRCLE);
feeWarningGlyph.getStyleClass().add("question-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getLockGlyph() {
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
lockGlyph.getStyleClass().add("lock-icon");
lockGlyph.setFontSize(12);
return lockGlyph;
}
private Glyph getUserGlyph() {
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER);
userGlyph.getStyleClass().add("user-icon");
userGlyph.setFontSize(12);
return userGlyph;
}
private Glyph getUserAddGlyph() {
Glyph userAddGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER_PLUS);
userAddGlyph.getStyleClass().add("useradd-icon");
@@ -1161,6 +1001,10 @@ public class TransactionDiagram extends GridPane {
}
}
public WalletTransaction getWalletTransaction() {
return walletTx;
}
public boolean isFinal() {
return finalProperty.get();
}
@@ -1173,6 +1017,18 @@ public class TransactionDiagram extends GridPane {
this.finalProperty.set(isFinal);
}
public TransactionDiagramLabel getLabel() {
return labelProperty.get();
}
public ObjectProperty<TransactionDiagramLabel> labelProperty() {
return labelProperty;
}
public void setLabelProperty(TransactionDiagramLabel label) {
this.labelProperty.set(label);
}
public OptimizationStrategy getOptimizationStrategy() {
return optimizationStrategyProperty.get();
}
@@ -1252,7 +1108,7 @@ public class TransactionDiagram extends GridPane {
}
}
private static class AdditionalPayment extends Payment {
public static class AdditionalPayment extends Payment {
private final List<Payment> additionalPayments;
public AdditionalPayment(List<Payment> additionalPayments) {
@@ -1260,10 +1116,10 @@ public class TransactionDiagram extends GridPane {
this.additionalPayments = additionalPayments;
}
public Glyph getOutputGlyph(TransactionDiagram transactionDiagram) {
public Glyph getOutputGlyph(WalletTransaction walletTx) {
Glyph glyph = null;
for(Payment payment : additionalPayments) {
Glyph paymentGlyph = transactionDiagram.getOutputGlyph(payment);
Glyph paymentGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
if(glyph != null && !paymentGlyph.getStyleClass().equals(glyph.getStyleClass())) {
return getPaymentGlyph();
}
@@ -1279,7 +1135,7 @@ public class TransactionDiagram extends GridPane {
}
}
private class OutputNode implements Comparable<OutputNode> {
private static class OutputNode {
public Pane outputLabel;
public Address address;
public long amount;
@@ -1289,18 +1145,9 @@ public class TransactionDiagram extends GridPane {
this.address = address;
this.amount = amount;
}
@Override
public int compareTo(TransactionDiagram.OutputNode o) {
try {
return getOutputIndex(address, amount) - getOutputIndex(o.address, o.amount);
} catch(Exception e) {
return 0;
}
}
}
private static class LabelContextMenu extends ContextMenu {
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
@@ -1316,6 +1163,7 @@ public class TransactionDiagram extends GridPane {
showAddress.setOnAction(event -> {
hide();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
qrDisplayDialog.initOwner(TransactionDiagram.this.getScene().getWindow());
qrDisplayDialog.showAndWait();
});
getItems().add(showAddress);
@@ -0,0 +1,270 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.text.Font;
import org.controlsfx.glyphfont.Glyph;
import java.util.*;
import java.util.stream.Collectors;
public class TransactionDiagramLabel extends HBox {
private final List<HBox> outputs = new ArrayList<>();
private final Button left;
private final Button right;
private final IntegerProperty displayedIndex = new SimpleIntegerProperty(-1);
public TransactionDiagramLabel() {
setSpacing(5);
setAlignment(Pos.CENTER_RIGHT);
left = new Button("");
left.setGraphic(getLeftGlyph());
left.setOnAction(event -> {
int index = displayedIndex.get();
if(index > 0) {
index--;
}
displayedIndex.set(index);
});
right = new Button("");
right.setGraphic(getRightGlyph());
right.setOnAction(event -> {
int index = displayedIndex.get();
if(index < outputs.size() - 1) {
index++;
}
displayedIndex.set(index);
});
displayedIndex.addListener((observable, oldValue, newValue) -> {
left.setDisable(newValue.intValue() <= 0);
right.setDisable(newValue.intValue() < 0 || newValue.intValue() >= outputs.size() - 1);
if(oldValue.intValue() >= 0 && oldValue.intValue() < outputs.size()) {
outputs.get(oldValue.intValue()).setVisible(false);
}
if(newValue.intValue() >= 0 && newValue.intValue() < outputs.size()) {
outputs.get(newValue.intValue()).setVisible(true);
}
});
}
public void update(TransactionDiagram transactionDiagram) {
getChildren().clear();
outputs.clear();
displayedIndex.set(-1);
double maxWidth = getMaxWidth();
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
List<OutputLabel> outputLabels = new ArrayList<>();
List<Payment> premixOutputs = walletTx.getPayments().stream().filter(walletTx::isPremixSend).collect(Collectors.toList());
if(!premixOutputs.isEmpty()) {
OutputLabel premixOutputLabel = getPremixOutputLabel(transactionDiagram, premixOutputs);
if(premixOutputLabel != null) {
outputLabels.add(premixOutputLabel);
}
Optional<Payment> optWhirlpoolFee = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.WHIRLPOOL_FEE).findFirst();
if(optWhirlpoolFee.isPresent()) {
OutputLabel whirlpoolFeeOutputLabel = getWhirlpoolFeeOutputLabel(transactionDiagram, optWhirlpoolFee.get(), premixOutputs);
outputLabels.add(whirlpoolFeeOutputLabel);
}
List<Payment> badbankOutputs = walletTx.getPayments().stream().filter(walletTx::isBadbankSend).collect(Collectors.toList());
List<OutputLabel> badbankOutputLabels = badbankOutputs.stream().map(payment -> getBadbankOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
outputLabels.addAll(badbankOutputLabels);
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_PREMIX && walletTx.getPayments().stream().anyMatch(walletTx::isPostmixSend)) {
OutputLabel mixOutputLabel = getMixOutputLabel(transactionDiagram, walletTx.getPayments());
if(mixOutputLabel != null) {
outputLabels.add(mixOutputLabel);
}
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
if(remixOutputLabel != null) {
outputLabels.add(remixOutputLabel);
}
} else {
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
}
outputLabels.addAll(paymentLabels);
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
outputLabels.addAll(mixes.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()));
}
Map<WalletNode, Long> changeMap = walletTx.getChangeMap();
outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList()));
OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram);
if(feeOutputLabel != null) {
outputLabels.add(feeOutputLabel);
}
for(OutputLabel outputLabel : outputLabels) {
maxWidth = Math.max(maxWidth, outputLabel.width);
outputs.add(outputLabel.hBox);
getChildren().add(outputLabel.hBox);
}
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER_RIGHT);
buttonBox.getChildren().addAll(left, right);
getChildren().add(buttonBox);
setMaxWidth(maxWidth);
setPrefWidth(maxWidth);
if(outputLabels.size() > 0) {
displayedIndex.set(0);
}
}
private OutputLabel getPremixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> premixOutputs) {
if(premixOutputs.isEmpty()) {
return null;
}
Payment premixOutput = premixOutputs.get(0);
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), premixOutput);
String text;
if(premixOutputs.size() == 1) {
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
} else {
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
}
return getOutputLabel(glyph, text);
}
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getWhirlpoolFeeOutputLabel(TransactionDiagram transactionDiagram, Payment whirlpoolFee, List<Payment> premixOutputs) {
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), whirlpoolFee);
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
return getOutputLabel(glyph, text);
}
private OutputLabel getMixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> mixOutputs) {
if(mixOutputs.isEmpty()) {
return null;
}
Payment remixOutput = mixOutputs.get(0);
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getPremixGlyph();
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
return getOutputLabel(glyph, text);
}
private OutputLabel getRemixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> remixOutputs) {
if(remixOutputs.isEmpty()) {
return null;
}
Payment remixOutput = remixOutputs.get(0);
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getPremixGlyph();
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Map.Entry<WalletNode, Long> changeEntry) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Glyph glyph = GlyphUtils.getChangeGlyph();
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
if(walletTx.getFee() < 0) {
return null;
}
Glyph glyph = GlyphUtils.getFeeGlyph();
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)";
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(Glyph glyph, String text) {
Label icon = new Label();
icon.setMinWidth(15);
glyph.setFontSize(12);
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setFont(Font.font("Roboto Mono Italic", 13));
label.setText(text);
HBox output = new HBox(5);
output.setAlignment(Pos.CENTER);
output.managedProperty().bind(output.visibleProperty());
output.setVisible(false);
output.getChildren().addAll(icon, label);
double lineWidth = TextUtils.computeTextWidth(label.getFont(), label.getText(), 0.0D) + 2 + getSpacing() + icon.getMinWidth() + 60;
return new OutputLabel(output, lineWidth, text);
}
public static Glyph getLeftGlyph() {
Glyph caretLeftGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_LEFT);
caretLeftGlyph.getStyleClass().add("label-left-icon");
caretLeftGlyph.setFontSize(15);
return caretLeftGlyph;
}
public static Glyph getRightGlyph() {
Glyph caretRightGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_RIGHT);
caretRightGlyph.getStyleClass().add("label-right-icon");
caretRightGlyph.setFontSize(15);
return caretRightGlyph;
}
private record OutputLabel(HBox hBox, double width, String text) {}
}
@@ -50,8 +50,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
getSortOrder().add(dateCol);
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateAll(WalletTransactionsEntry rootEntry) {
@@ -61,16 +60,13 @@ public class TransactionsTreeTable extends CoinTreeTable {
setRoot(rootItem);
rootItem.setExpanded(true);
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
TreeTableColumn<Entry, ?> dateCol = getColumns().get(0);
getSortOrder().add(dateCol);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
}
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateHistory() {
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
sort();
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateLabel(Entry entry) {
@@ -0,0 +1,46 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.control.TreeTableColumn;
public class TreeTableColumnConfig {
private final int index;
private final Integer width;
private final TreeTableColumn.SortType sortType;
public TreeTableColumnConfig(int index, Integer width, TreeTableColumn.SortType sortType) {
this.index = index;
this.width = width;
this.sortType = sortType;
}
public TreeTableColumnConfig(int index, String width, String sortType) {
this.index = index;
this.width = width.isEmpty() ? null : Integer.valueOf(width, 10);
this.sortType = sortType.isEmpty() ? null : TreeTableColumn.SortType.valueOf(sortType);
}
public int getIndex() {
return index;
}
public Integer getWidth() {
return width;
}
public TreeTableColumn.SortType getSortType() {
return sortType;
}
public String toString() {
return index + "-" + (width == null ? "" : width) + "-" + (sortType == null ? "" : sortType);
}
public static TreeTableColumnConfig fromString(String columnConfig) {
String[] parts = columnConfig.split("-", 3);
if(parts.length == 3) {
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), parts[1], parts[2]);
}
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), (Integer)null, null);
}
}
@@ -0,0 +1,42 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.TreeTableColumn;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
public class TreeTableConfig {
private final List<TreeTableColumnConfig> columnConfigs;
public TreeTableConfig(CoinTreeTable treeTable) {
columnConfigs = new ArrayList<>();
TreeTableColumn<Entry, ?> sortColumn = treeTable.getSortOrder().isEmpty() ? null : treeTable.getSortOrder().get(0);
for(int i = 0; i < treeTable.getColumns().size(); i++) {
TreeTableColumn<Entry, ?> column = treeTable.getColumns().get(i);
//TODO: Support column widths
columnConfigs.add(new TreeTableColumnConfig(i, null, sortColumn == column ? column.getSortType() : null));
}
}
public TreeTableConfig(List<TreeTableColumnConfig> columnConfigs) {
this.columnConfigs = columnConfigs;
}
public String toString() {
StringJoiner joiner = new StringJoiner("|");
columnConfigs.stream().forEach(col -> joiner.add(col.toString()));
return joiner.toString();
}
public static TreeTableConfig fromString(String tableConfig) {
List<TreeTableColumnConfig> columnConfigs = new ArrayList<>();
String[] parts = tableConfig.split("\\|");
for(String part : parts) {
columnConfigs.add(TreeTableColumnConfig.fromString(part));
}
return new TreeTableConfig(columnConfigs);
}
}
@@ -27,7 +27,8 @@ public class UsbStatusButton extends MenuButton {
public void setDevices(List<Device> devices) {
for(Device device : devices) {
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T ||
device.getModel() == WalletModel.TREZOR_SAFE_3 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
toggleItem.setOnAction(event -> {
@@ -83,8 +83,7 @@ public class UtxosTreeTable extends CoinTreeTable {
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
getSortOrder().add(amountCol);
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
}
@@ -96,17 +95,14 @@ public class UtxosTreeTable extends CoinTreeTable {
setRoot(rootItem);
rootItem.setExpanded(true);
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
TreeTableColumn<Entry, ?> amountCol = getColumns().get(getColumns().size() - 1);
getSortOrder().add(amountCol);
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
}
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
}
public void updateHistory() {
//Utxo entries should have already been updated, so only a resort required
if(!getRoot().getChildren().isEmpty()) {
sort();
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
}
}
@@ -21,12 +21,12 @@ import java.util.Date;
public class WalletBirthDateDialog extends Dialog<Date> {
private final DatePicker birthDatePicker;
public WalletBirthDateDialog(Date birthDate) {
public WalletBirthDateDialog(Date birthDate, boolean singleAddress) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first wallet transaction:");
setTitle(singleAddress ? "Address Scan Start Date" : "Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first " + (singleAddress ? "" : "wallet") + " transaction:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(420);
@@ -58,7 +58,7 @@ public class WalletBirthDateDialog extends Dialog<Date> {
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType(singleAddress ? "Scan Address" : "Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> birthDatePicker.getValue() == null, birthDatePicker.valueProperty());
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.io.*;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
@@ -17,7 +18,9 @@ import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> {
private Wallet wallet;
public WalletExportDialog(Wallet wallet) {
public WalletExportDialog(WalletForm walletForm) {
this.wallet = walletForm.getWallet();
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -42,9 +45,10 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow());
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(),
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -64,6 +68,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(480);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
@@ -19,9 +19,10 @@ import javafx.scene.shape.Circle;
import org.controlsfx.glyphfont.Glyph;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
@@ -36,10 +37,18 @@ public class WalletIcon extends StackPane {
private final Storage storage;
private final ObjectProperty<Wallet> walletProperty = new SimpleObjectProperty<>();
private final Keystore keystore;
private final Glyph fallbackIcon;
public WalletIcon(Storage storage, Wallet wallet) {
this(storage, wallet, null, null);
}
public WalletIcon(Storage storage, Wallet wallet, Keystore keystore, Glyph fallbackIcon) {
super();
this.storage = storage;
this.keystore = keystore;
this.fallbackIcon = fallbackIcon;
setPrefSize(WIDTH, HEIGHT);
walletProperty.addListener((observable, oldValue, newValue) -> {
refresh();
@@ -58,8 +67,8 @@ public class WalletIcon extends StackPane {
} else {
Platform.runLater(() -> addWalletIcon(walletId));
}
} else if(wallet.getKeystores().size() == 1) {
Keystore keystore = wallet.getKeystores().get(0);
} else if(this.keystore != null || wallet.getKeystores().size() == 1) {
Keystore keystore = this.keystore == null ? wallet.getKeystores().get(0) : this.keystore;
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getSource() == KeystoreSource.HW_AIRGAPPED) {
WalletModel walletModel = keystore.getWalletModel();
@@ -96,7 +105,10 @@ public class WalletIcon extends StackPane {
}
if(getChildren().isEmpty()) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
Glyph glyph = fallbackIcon;
if(glyph == null) {
glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
}
glyph.setFontSize(10.0);
getChildren().add(glyph);
}
@@ -126,7 +138,7 @@ public class WalletIcon extends StackPane {
}
private void addWalletIcon(String walletId) {
Image image = new Image(PROTOCOL + ":" + walletId + "?" + QUERY, WIDTH, HEIGHT, true, false);
Image image = new Image(PROTOCOL + ":" + walletId.replaceAll(" ", "%20").replaceAll("#", "%23") + "?" + QUERY, WIDTH, HEIGHT, true, false);
getChildren().clear();
Circle circle = new Circle(getPrefWidth() / 2,getPrefHeight() / 2,getPrefWidth() / 2);
circle.setFill(new ImagePattern(image));
@@ -165,6 +177,8 @@ public class WalletIcon extends StackPane {
}
public static class WalletIconStreamHandler extends URLStreamHandler {
private static final Logger log = LoggerFactory.getLogger(WalletIconStreamHandler.class);
@Override
protected URLConnection openConnection(URL url) throws IOException {
return new URLConnection(url) {
@@ -175,17 +189,19 @@ public class WalletIcon extends StackPane {
@Override
public InputStream getInputStream() throws IOException {
String walletId = url.getPath();
String walletId = url.getPath().replaceAll("%20", " ").replaceAll("%23", "#");
String query = url.getQuery();
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet == null) {
throw new IOException("Cannot find wallet for wallet id " + walletId);
log.warn("Cannot find wallet for wallet id " + walletId);
return getFallbackIconStream();
}
if(query.startsWith(QUERY)) {
if(wallet.getWalletConfig() == null || wallet.getWalletConfig().getIconData() == null) {
throw new IOException("No icon data for " + walletId);
log.warn("No icon data for " + walletId);
return getFallbackIconStream();
}
ByteArrayInputStream bais = new ByteArrayInputStream(wallet.getWalletConfig().getIconData());
@@ -196,7 +212,12 @@ public class WalletIcon extends StackPane {
}
}
throw new MalformedURLException("Cannot load url " + url);
log.warn("Cannot load url " + url);
return getFallbackIconStream();
}
private static InputStream getFallbackIconStream() {
return WalletIconStreamHandler.class.getResourceAsStream("/image/sparrow.png");
}
};
}
@@ -9,6 +9,7 @@ import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
@@ -16,6 +17,7 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -24,7 +26,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
private final Accordion importAccordion;
private final Button scanButton;
public WalletImportDialog() {
public WalletImportDialog(List<WalletForm> selectedWalletForms) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -49,7 +51,8 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(),
new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux(), new AirGapVault(), new Samourai());
for(KeystoreFileImport importer : keystoreImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
@@ -57,7 +60,11 @@ public class WalletImportDialog extends Dialog<Wallet> {
}
}
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(),
new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig()));
if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms));
}
for(WalletImport importer : walletImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
@@ -85,6 +92,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(600);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
@@ -34,9 +34,21 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
}
public WalletNameDialog(String initialName) {
this(initialName, false);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions) {
this(initialName, hasExistingTransactions, null);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate) {
this(initialName, hasExistingTransactions, startDate, false);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate, boolean rename) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
boolean requestBirthDate = !rename && (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
setTitle("Wallet Name");
dialogPane.setHeaderText("Enter a name for this wallet:");
@@ -91,6 +103,12 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
if(requestBirthDate) {
content.getChildren().add(existingBox);
if(hasExistingTransactions) {
existingCheck.setSelected(true);
}
if(startDate != null) {
existingPicker.setValue(startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
}
}
dialogPane.setContent(content);
@@ -107,16 +125,16 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType((rename ? "Rename" : "Create") + " Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
name.getText().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
name.getText().trim().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
okButton.disableProperty().bind(isInvalid);
name.setPromptText("Wallet Name");
Platform.runLater(name::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText(), existingPicker.getValue()) : null);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText().trim(), existingPicker.getValue()) : null);
}
public static class NameAndBirthDate {
@@ -0,0 +1,218 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.Function;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Side;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class WalletSummaryDialog extends Dialog<Void> {
public WalletSummaryDialog(List<List<WalletForm>> summaryWalletFormsList) {
List<List<WalletForm>> walletFormsList = new ArrayList<>(summaryWalletFormsList);
walletFormsList.removeIf(List::isEmpty);
if(walletFormsList.isEmpty()) {
throw new IllegalArgumentException("No wallets selected to summarize");
}
boolean allOpenWallets = walletFormsList.size() > 1;
List<Wallet> masterWallets = walletFormsList.stream().map(walletForms -> walletForms.get(0).getMasterWallet()).toList();
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.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/transactions.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Wallet Summary for " + (allOpenWallets ? "All Open Wallets" : masterWallets.get(0).getName()));
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);
}
HBox hBox = new HBox(40);
CoinTreeTable table = new CoinTreeTable();
TreeTableColumn<Entry, String> nameColumn = new TreeTableColumn<>("Wallet");
nameColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getLabel());
});
nameColumn.setCellFactory(p -> new LabelCell());
table.getColumns().add(nameColumn);
TreeTableColumn<Entry, Number> balanceColumn = new TreeTableColumn<>("Balance");
balanceColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
});
balanceColumn.setCellFactory(p -> new CoinCell());
table.getColumns().add(balanceColumn);
table.setUnitFormat(masterWallets.get(0));
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
TreeTableColumn<Entry, Number> fiatColumn = new TreeTableColumn<>(currencyRate.getCurrency().getSymbol());
fiatColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
});
fiatColumn.setCellFactory(p -> new FiatCell());
table.getColumns().add(fiatColumn);
table.setCurrencyRate(currencyRate);
}
Entry rootEntry = allOpenWallets ? new AllSummaryEntry(walletFormsList) : new SummaryEntry(walletFormsList.get(0));
TreeItem<Entry> rootItem = new TreeItem<>(rootEntry);
for(Entry childEntry : rootEntry.getChildren()) {
TreeItem<Entry> childItem = new TreeItem<>(childEntry);
rootItem.getChildren().add(childItem);
if(allOpenWallets) {
for(Entry walletEntry : childEntry.getChildren()) {
TreeItem<Entry> walletItem = new TreeItem<>(walletEntry);
childItem.getChildren().add(walletItem);
walletItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)walletEntry)));
}
} else {
childItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)childEntry)));
}
}
table.setShowRoot(true);
table.setRoot(rootItem);
rootItem.setExpanded(true);
table.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
table.setPrefWidth(450);
VBox vBox = new VBox();
vBox.getChildren().add(table);
hBox.getChildren().add(vBox);
Wallet balanceWallet;
if(allOpenWallets) {
balanceWallet = new Wallet();
balanceWallet.getChildWallets().addAll(masterWallets.stream().flatMap(mws -> mws.getAllWallets().stream()).toList());
} else {
balanceWallet = masterWallets.get(0);
}
NumberAxis xAxis = new NumberAxis();
xAxis.setSide(Side.BOTTOM);
xAxis.setForceZeroInRange(false);
xAxis.setMinorTickVisible(false);
NumberAxis yAxis = new NumberAxis();
yAxis.setSide(Side.LEFT);
BalanceChart balanceChart = new BalanceChart(xAxis, yAxis);
balanceChart.initialize(new WalletTransactionsEntry(balanceWallet, true));
balanceChart.setAnimated(false);
balanceChart.setLegendVisible(false);
balanceChart.setVerticalGridLinesVisible(false);
hBox.getChildren().add(balanceChart);
getDialogPane().setContent(hBox);
ButtonType okButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
}
public static class AllSummaryEntry extends Entry {
private AllSummaryEntry(List<List<WalletForm>> walletFormsList) {
super(null, "All Wallets", walletFormsList.stream().map(SummaryEntry::new).collect(Collectors.toList()));
}
@Override
public Long getValue() {
long value = 0;
for(Entry entry : getChildren()) {
value += entry.getValue();
}
return value;
}
@Override
public String getEntryType() {
return null;
}
@Override
public Function getWalletFunction() {
return Function.TRANSACTIONS;
}
}
public static class SummaryEntry extends Entry {
private SummaryEntry(List<WalletForm> walletForms) {
super(walletForms.get(0).getWallet(), walletForms.get(0).getWallet().getName(), walletForms.stream().map(WalletForm::getWalletTransactionsEntry).collect(Collectors.toList()));
}
@Override
public Long getValue() {
long value = 0;
for(Entry entry : getChildren()) {
value += entry.getValue();
}
return value;
}
@Override
public String getEntryType() {
return null;
}
@Override
public Function getWalletFunction() {
return Function.TRANSACTIONS;
}
}
public static class UnconfirmedEntry extends Entry {
private final WalletTransactionsEntry walletTransactionsEntry;
private UnconfirmedEntry(WalletTransactionsEntry walletTransactionsEntry) {
super(walletTransactionsEntry.getWallet(), "Unconfirmed", Collections.emptyList());
this.walletTransactionsEntry = walletTransactionsEntry;
}
@Override
public Long getValue() {
return walletTransactionsEntry.getMempoolBalance();
}
@Override
public String getEntryType() {
return null;
}
@Override
public Function getWalletFunction() {
return Function.TRANSACTIONS;
}
}
}
@@ -5,6 +5,7 @@ import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import com.sparrowwallet.bokmakierie.Bokmakierie;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@@ -14,16 +15,22 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import net.sourceforge.zbar.ZBar;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private WebcamResolution resolution;
private WebcamDevice device;
private final WebcamListener listener;
@@ -37,6 +44,7 @@ public class WebcamService extends ScheduledService<Image> {
private Webcam cam;
private long lastQrSampleTime;
private final Reader qrReader;
private final Bokmakierie bokmakierie;
static {
Webcam.setDriver(new WebcamScanDriver());
@@ -49,6 +57,7 @@ public class WebcamService extends ScheduledService<Image> {
this.delayCalculator = delayCalculator;
this.lastQrSampleTime = System.currentTimeMillis();
this.qrReader = new QRCodeReader();
this.bokmakierie = new Bokmakierie();
}
@Override
@@ -137,6 +146,9 @@ public class WebcamService extends ScheduledService<Image> {
if(result == null) {
result = readQR(croppedImage);
}
if(result == null) {
result = readQR(invert(croppedImage));
}
if(result != null) {
resultProperty.set(result);
@@ -147,6 +159,22 @@ public class WebcamService extends ScheduledService<Image> {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
com.sparrowwallet.bokmakierie.Result result = bokmakierie.scan(bufferedImage);
if(result != null) {
return new Result(result.getMessage(), result.getRawBytes(), new ResultPoint[0], BarcodeFormat.QR_CODE);
}
} catch(Exception e) {
log.debug("Error scanning QR", e);
}
if(ZBar.isEnabled()) {
ZBar.Scan scan = ZBar.scan(bufferedImage);
if(scan != null) {
return new Result(scan.stringData(), scan.rawData(), new ResultPoint[0], BarcodeFormat.QR_CODE);
}
}
try {
return qrReader.decode(bitmap, Map.of(DecodeHintType.TRY_HARDER, Boolean.TRUE));
} catch(ReaderException e) {
@@ -175,6 +203,29 @@ public class WebcamService extends ScheduledService<Image> {
return new CroppedDimension(x, y, squareSize);
}
public BufferedImage invert(BufferedImage inImg) {
try {
int width = inImg.getWidth();
int height = inImg.getHeight();
BufferedImage outImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
WritableRaster outRaster = outImg.getRaster();
WritableRaster inRaster = inImg.getRaster();
for(int y = 0; y < height; y++) {
for(int x = 0; x < width; x++) {
for(int i = 0; i < outRaster.getNumBands(); i++) {
outRaster.setSample(x, y, i, 255 - inRaster.getSample(x, y, i));
}
}
}
return outImg;
} catch(Exception e) {
log.warn("Error inverting image", e);
return inImg;
}
}
public Result getResult() {
return resultProperty.get();
}
@@ -28,16 +28,18 @@ import java.util.List;
public class XprvKeystoreImportPane extends TitledDescriptionPane {
protected final Wallet wallet;
protected final KeystoreXprvImport importer;
protected final KeyDerivation defaultDerivation;
private Button enterXprvButton;
private SplitMenuButton importButton;
private ExtendedKey xprv;
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer) {
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton();
buttonBox.getChildren().add(importButton);
@@ -47,6 +49,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
super("Master Private Key", "BIP32 key", "", "image/" + WalletModel.SEED.getType() + ".png");
this.wallet = null;
this.importer = null;
this.defaultDerivation = keystore.getKeyDerivation();
try {
this.xprv = keystore.getExtendedMasterPrivateKey();
@@ -79,7 +82,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation());
importKeystore(getDefaultDerivation());
});
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;
@@ -97,6 +100,10 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
private void enterXprv() {
setDescription("Enter master private key");
showHideLink.setVisible(false);
@@ -149,7 +156,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton.setVisible(true);
setDescription("Ready to import");
xprv = ExtendedKey.fromDescriptor(xprvField.getText());
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
setContent(getDerivationEntry(getDefaultDerivation()));
});
xprvField.textProperty().addListener((observable, oldValue, newValue) -> {
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.sparrow.net.TlsServerException;
public class ConnectionFailedEvent {
@@ -20,6 +21,12 @@ public class ConnectionFailedEvent {
Throwable cause = (exception.getCause() != null ? exception.getCause() : exception);
cause = (cause.getCause() != null ? cause.getCause() : cause);
if(cause instanceof JsonRpcException jsonRpcException && jsonRpcException.getErrorMessage() != null) {
return jsonRpcException.getErrorMessage().getMessage() +
(jsonRpcException.getErrorMessage().getData() != null ? " (" + jsonRpcException.getErrorMessage().getData().asText() + ")" : "");
}
String message = splitCamelCase(cause.getClass().getSimpleName().replace("Exception", "Error"));
return message + " (" + cause.getMessage() + ")";
}
@@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CormorantPruneStatusEvent extends CormorantStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd");
private final Wallet wallet;
private final Date scanDate;
private final Date pruneDate;
private final boolean legacyWalletExists;
public CormorantPruneStatusEvent(String status, Wallet wallet, Date scanDate, Date pruneDate, boolean legacyWalletExists) {
super(status);
this.wallet = wallet;
this.scanDate = scanDate;
this.pruneDate = pruneDate;
this.legacyWalletExists = legacyWalletExists;
}
@Override
public boolean isFor(Wallet wallet) {
return this.wallet == wallet;
}
public Wallet getWallet() {
return wallet;
}
public Date getScanDate() {
return scanDate;
}
public String getScanDateAsString() {
return DATE_FORMAT.format(scanDate);
}
public Date getPruneDate() {
return pruneDate;
}
public String getPruneDateAsString() {
return DATE_FORMAT.format(pruneDate);
}
public boolean legacyWalletExists() {
return legacyWalletExists;
}
}
@@ -0,0 +1,48 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.time.Duration;
import java.util.Set;
public class CormorantScanStatusEvent extends CormorantStatusEvent {
private final Set<Wallet> scanningWallets;
private final int progress;
private final Duration remainingDuration;
public CormorantScanStatusEvent(String status, Set<Wallet> scanningWallets, int progress, Duration remainingDuration) {
super(status);
this.scanningWallets = scanningWallets;
this.progress = progress;
this.remainingDuration = remainingDuration;
}
@Override
public boolean isFor(Wallet wallet) {
return scanningWallets.contains(wallet);
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Duration getRemaining() {
return remainingDuration;
}
public String getRemainingAsString() {
if(remainingDuration != null) {
if(progress < 30) {
return Math.round((double)remainingDuration.toSeconds() / 60) + "m";
} else {
return remainingDuration.toMinutesPart() + "m " + remainingDuration.toSecondsPart() + "s";
}
}
return "";
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public abstract class CormorantStatusEvent {
private final String status;
public CormorantStatusEvent(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public abstract boolean isFor(Wallet wallet);
}
@@ -0,0 +1,41 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CormorantSyncStatusEvent extends CormorantStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm");
private final int progress;
private final Date tip;
public CormorantSyncStatusEvent(String status, int progress, Date tip) {
super(status);
this.progress = progress;
this.tip = tip;
}
@Override
public boolean isFor(Wallet wallet) {
return true;
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Date getTip() {
return tip;
}
public String getTipAsString() {
return tip == null ? "" : DATE_FORMAT.format(tip);
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.address.Address;
public class DeviceAddressEvent {
private final Address address;
public DeviceAddressEvent(Address address) {
this.address = address;
}
public Address getAddress() {
return address;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
public class DeviceGetPrivateKeyEvent {
private final ECKey privateKey;
private final ScriptType scriptType;
public DeviceGetPrivateKeyEvent(ECKey privateKey, ScriptType scriptType) {
this.privateKey = privateKey;
this.scriptType = scriptType;
}
public ECKey getPrivateKey() {
return privateKey;
}
public ScriptType getScriptType() {
return scriptType;
}
}

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