Compare commits

...
222 Commits
Author SHA1 Message Date
Craig Raw e7f6f7f3db add show version to command line args 2022-10-27 11:49:20 +02:00
Craig Raw dd9868c918 v1.7.0 2022-10-27 11:10:50 +02:00
Craig Raw c2d3afae59 fix exception when clearing a server alias 2022-10-27 09:34:32 +02:00
Craig Raw 04a516d56b improve error messaging for payjoin requests 2022-10-27 08:11:58 +02:00
Craig Raw b27709e96f show network in main tab header background when not using mainnet 2022-10-26 15:54:59 +02:00
Craig Raw ebb7d23a05 hide mix failed after timeout 2022-10-26 12:40:17 +02:00
Craig Raw 97d121244f add support for deprecating importers and exporters, and deprecate cobo vault 2022-10-26 12:28:21 +02:00
Craig Raw 60dbc8ed84 add cancel transaction via rbf to unconfirmed tx context menu 2022-10-26 11:33:36 +02:00
Craig Raw 29cd321724 avoid showing usb signing dialog for watch only keystores in multisig wallets 2022-10-26 08:31:36 +02:00
Craig Raw 467b834955 add jade as airgapped keystore importer 2022-10-25 14:41:10 +02:00
Craig Raw 3bfa5d3dc4 specify arch in release tarball name 2022-10-25 12:02:00 +02:00
Craig Raw f501f08e17 only build headless when specifically requested 2022-10-25 11:27:37 +02:00
Craig Raw 7d796369d6 upgrade and fix action 2022-10-25 10:45:48 +02:00
Craig Raw c572011578 followup 2022-10-25 10:23:17 +02:00
Craig Raw dbc1e7746b configure headless build 2022-10-25 10:04:03 +02:00
Craig Raw 9325a1968b explicitly detect java.awt.headless in build 2022-10-25 09:38:16 +02:00
Craig Raw 85166635b4 followup 2022-10-25 08:52:23 +02:00
Craig Raw ed69a86529 improve detection and handling on headless systems 2022-10-25 08:34:31 +02:00
Craig Raw ab2c77695b show warning dialog when a legacy multi output descriptor is entered 2022-10-24 16:50:08 +02:00
Craig Raw 6ad81e1228 suppress verbose whirlpool connection related logging errors 2022-10-24 15:31:42 +02:00
Craig Raw ff340c2449 followup to test taskbar user attention feature support 2022-10-24 14:43:53 +02:00
Craig Raw f2b0f8ca9e followup to test taskbar api platform support 2022-10-24 14:06:38 +02:00
Craig Raw 0c213294ad request user attention via taskbar when soroban communication requires action 2022-10-24 14:00:20 +02:00
Craig Raw 7cdb7319ee multisig backup dialog tweaks 2022-10-24 13:05:44 +02:00
Craig Raw d1ff8d6e3e fix show/edit descriptor button height 2022-10-24 12:51:21 +02:00
Craig Raw d9ddc74d73 disable rbf if allow unconfirmed preference is disabled 2022-10-24 08:18:17 +02:00
Craig Raw 603df6d0f6 terminal - fix add account encryption and normalize ui widths 2022-10-20 12:45:09 +02:00
Craig Raw cbf847a57f terminal - lock wallet 2022-10-19 18:41:23 +02:00
Craig Raw e8fb676a24 terminal - show seed 2022-10-19 18:19:49 +02:00
Craig Raw 273f3043fb terminal - add account 2022-10-19 18:04:55 +02:00
Craig Raw 8f165b05c7 terminal - create watch only wallet 2022-10-19 15:37:14 +02:00
Craig Raw 8eb092a8d6 terminal - create bip39 wallet 2022-10-19 09:44:44 +02:00
Craig Raw 8dd1850905 add settings dialog and other terminal improvements 2022-10-18 12:42:44 +02:00
Craig Raw 0fa6bd56e2 add interface enum 2022-10-17 13:52:40 +02:00
Craig Raw c4c581525a remove stdout appender in terminal mode 2022-10-17 13:30:21 +02:00
Craig Raw d6a3824690 add linux aarch64 hwi 2022-10-12 11:25:49 +02:00
Craig Raw 7dba141073 add linux aarch64 bwt 2022-10-12 10:56:24 +02:00
Craig Raw f2f6e639dc minor fixes 2022-10-11 15:40:50 +02:00
Craig Raw 78afc5e4d5 fix no recent wallets issue 2022-10-11 15:26:14 +02:00
Craig Raw b2d85b6c78 detect and configure build for headless environments 2022-10-11 15:12:40 +02:00
Craig Raw f3b0d37c54 replace and customize javafx plugin for headless environments 2022-10-11 15:07:43 +02:00
Craig Raw 6768ad2028 improve close timing of wallet loading dialog 2022-10-11 12:45:24 +02:00
Craig Raw 81cde4756a fix threading issue on terminal wallet load 2022-10-11 12:16:18 +02:00
Craig Raw 778564a954 refactor and rename launch and application classes 2022-10-11 11:41:49 +02:00
Craig Raw 1e4c8c3837 separate application from main 2022-10-10 16:37:35 +02:00
Craig Raw 22408103ea show additional inputs in tx diagram as labels or abbreviated txid:index 2022-10-10 09:55:38 +02:00
Craig Raw 383d3954d8 add linux aarch64 libsecp256k1 2022-10-06 14:35:49 +02:00
Craig Raw 77a4e4aa50 suppress unnecessary unchecked cast warning 2022-10-06 13:38:32 +02:00
Craig Raw 19dedfa070 implement terminal mode 2022-10-06 13:10:18 +02:00
Craig Raw 52696b014f import wallet from output descriptor pdf, ignore newline characters in output descriptor dialog 2022-09-26 13:48:49 +02:00
Craig Raw 8fb6de85f1 add unit format menu selection for alternative grouping and decimal separators 2022-09-21 11:36:13 +02:00
Craig Raw 8270eb71db install context menu handler for anywhere within transaction diagram 2022-09-16 15:08:03 +02:00
Craig Raw edcf12de5a follow up 2022-09-16 12:46:39 +02:00
Craig Raw da3399468c save transaction diagram as image through context menu on transaction label 2022-09-16 12:12:25 +02:00
Craig Raw 923c61fceb fix import of electrum wallet without keystore labels 2022-09-14 11:15:15 +02:00
Craig Raw d3d939889e increase maximum gap limit, but display warning when gap limit is over 999 2022-09-14 11:06:15 +02:00
Craig Raw 2b4d3fac6c recommend backup of output descriptor when saving new multisig wallets 2022-09-14 10:42:41 +02:00
Craig Raw 1f67692727 add support for configuring server aliases, and switching servers via the tools menu 2022-09-12 15:44:47 +02:00
Craig Raw bacbdb848b add move left and right context menu items to reorder wallet and transaction tabs 2022-09-06 12:42:18 +02:00
Craig Raw 51ba7fc4cf add context menu item to addresses table to spend all utxos for an address 2022-09-05 14:37:05 +02:00
Craig Raw 59efed9e42 verify signatures immediately after signing as per recommendation in bip340 2022-09-02 11:27:30 +02:00
craigrawandGitHub 193fea4b69 Merge pull request #675 from nyxnor/repro-docs
improve reproducible docs
2022-08-30 09:24:43 +02:00
nyxnor b5cc2ff4c3 improve reproducible docs 2022-08-29 16:58:05 +00:00
Craig Raw 252ec58065 fix typo in build doc 2022-08-29 08:51:19 +02:00
Craig Raw b8979ed8b0 use compact parameter to improve paynym search performance 2022-08-24 11:46:53 +02:00
Craig Raw c24f953e52 export all related wallets when exporting to electrum personal server 2022-08-23 08:48:35 +02:00
Craig Raw d139ca2706 add wallet export to electrum personal server config file 2022-08-22 14:33:03 +02:00
Craig Raw bd421e877a followup 2022-08-22 11:35:04 +02:00
Craig Raw 25e1250710 add context menu item in transaction diagram to show input and output addresses as qrs 2022-08-22 11:34:03 +02:00
Craig Raw fad1dad76e add trace logging to electrum server calls 2022-08-17 11:11:39 +02:00
Craig Raw b3bd42b8f6 add logging to all external api calls 2022-08-17 10:59:33 +02:00
Craig Raw 5aea538f09 cancel whirlpool startup service when disconnecting 2022-08-16 10:45:01 +02:00
Craig Raw 16755e3140 freeze and unfreeze any utxos from address cell context menu 2022-08-11 10:26:23 +02:00
Craig Raw cbfb7230a8 set transaction label on soroban collaborative transactions 2022-08-10 15:25:44 +02:00
Craig Raw e438389953 allow soroban initiator to try again if meeting request fails 2022-08-10 14:15:40 +02:00
Craig Raw 6534ccb07e accept output descriptor fragments in pubkey qr scanner 2022-08-10 12:31:43 +02:00
Craig Raw ca782dfc69 avoid resolution of onion hosts when creating proxied server socket addresses 2022-08-10 12:02:23 +02:00
Craig Raw f1a662ba8a fix name length on coldcard multisig export and update help text 2022-08-08 13:46:37 +02:00
Craig Raw 33fb2a38fc fix reproducibility issue by avoiding objcopy use during build, remove unnecessary java native commands from binaries 2022-08-08 11:23:29 +02:00
Craig Raw 8b22e057bf update documentation to reference java 18.0.1 as current release jdk 2022-08-04 14:35:59 +02:00
Craig Raw 68238e4e88 v1.6.6 2022-08-04 11:19:18 +02:00
Craig Raw 80fab6df99 add support for lnurl-auth authentication by registering a platform uri handler 2022-08-04 11:15:17 +02:00
Craig Raw 6efe5e4ccc update build to java 18.0.1 2022-08-02 13:38:45 +02:00
Craig Raw 4c36d27d17 add help menu item to open telegram support 2022-08-02 11:40:42 +02:00
Craig Raw cc8dd59dbc invalidate multisig addresses for message signing and include address tooltip for guidance 2022-08-02 11:21:00 +02:00
Craig Raw 7e91f57a42 avoid saving frequently changing tls certificates for blockchain.info public servers to avoid approval complacency 2022-08-01 15:39:48 +02:00
Craig Raw f4c8bfa48c avoid saving xpubs on bip47 wallets, restore from seed on opening 2022-08-01 14:17:46 +02:00
Craig Raw e0a14fdea6 use locale-insensitive lowercase and uppercase functions 2022-07-29 09:45:57 +02:00
Craig Raw b4af3586dc explicitly name functions in strings 2022-07-28 16:09:10 +02:00
Craig Raw 28722d385b test loading fxml with system specific file separator 2022-07-28 15:11:59 +02:00
Craig Raw dab6b9663a copy existing labels from deposit utxos into badbank utxos if present 2022-07-28 13:13:52 +02:00
Craig Raw 4e08334a3a delete temporary hwi pyinstaller extraction if hwi crashes 2022-07-28 10:59:44 +02:00
Craig Raw dcb261a631 fix issue adding accounts to multisig wallets with mixed watch-only and seed keystores 2022-07-27 12:27:01 +02:00
Craig Raw 258fe34101 refactor transport and speedup private server delay on connection failure 2022-07-27 11:02:01 +02:00
Craig Raw 04917c45b6 disallow duplicate xpubs in multisig wallet keystores 2022-07-25 14:47:08 +02:00
Craig Raw 08934d3c3c implement auth47 authentication through platform uri registration 2022-07-25 12:48:21 +02:00
Craig Raw 192657fa69 revert commit hash in about dialog 2022-07-21 15:55:25 +02:00
Craig Raw 9c40c56c6c followup #3 2022-07-21 15:08:30 +02:00
Craig Raw c4d0e4bac9 followup #2 2022-07-21 15:03:27 +02:00
Craig Raw a62b14c8e4 followup 2022-07-21 14:58:33 +02:00
Craig Raw 3ec800e5e8 include current git commit hash in about dialog 2022-07-21 14:51:14 +02:00
Craig Raw f30da06aaf add optional transaction count column on address table with table header context menu to show 2022-07-21 13:33:19 +02:00
Craig Raw 03e9d23fa8 update jlink plugin for java 18 2022-07-21 10:44:34 +02:00
Craig Raw 7dae9496ba fix case insensitive matching on address and txo entries 2022-07-21 10:16:49 +02:00
Craig Raw b621cac18d upgrade to gradle 7.5 2022-07-21 09:49:01 +02:00
Craig Raw 13a576e871 improve transaction entry sort and unconfirmed tx tooltip 2022-07-20 16:56:26 +02:00
Craig Raw 19551671bd followup 2022-07-19 13:25:46 +02:00
Craig Raw ecf9b78753 implement sweeping of funds from a bip38 encrypted private key 2022-07-19 13:22:20 +02:00
Craig Raw 22303a2efc only allow sending to paynyms where a notification transaction has previously been sent 2022-07-19 10:34:31 +02:00
Craig Raw 60aa20ac55 improve performance on deep wallets by storing addresses 2022-07-18 16:12:32 +02:00
Craig Raw 11cda40a40 address and related optimizations 2022-07-15 13:42:47 +02:00
Craig Raw ebbc4289e1 remove warmup key derivation, indicate when creating wallet transaction is deriving keys 2022-07-14 16:09:22 +02:00
Craig Raw ac64811b35 follow up 2022-07-14 15:38:01 +02:00
Craig Raw 7408744100 prevent concurrent modification exception while copying node tree 2022-07-14 15:30:55 +02:00
Craig Raw 326fae88af optimize retrieving unspent utxos 2022-07-14 15:08:17 +02:00
Craig Raw 0be73efdc1 indicate number of utxos selected in utxos tab 2022-07-14 14:49:25 +02:00
Craig Raw 486027f153 use address node map to optimize transaction diagram and privacy analysis 2022-07-14 13:42:14 +02:00
Craig Raw e42fc9a033 cache the wallet nodes for provided addresses during transaction construction 2022-07-14 13:21:46 +02:00
Craig Raw fc52670b2d warm pubkey cache by deriving all public keys on wallet opening 2022-07-14 09:56:48 +02:00
Craig Raw 4217de15a3 avoid unnecessary computation during entry cell sizing on table scrolls 2022-07-14 08:35:31 +02:00
Craig Raw 2d42ebff13 update h2 to 2.1.214 2022-07-13 14:06:29 +02:00
Craig Raw e629a51901 include key derivation optimisation 2022-07-12 10:44:00 +02:00
Craig Raw 91273c2192 show custom error dialog for min relay fee not met broadcast errors 2022-07-11 17:07:12 +02:00
Craig Raw f8fce02a3d add context menus to transaction diagram labels to copy addresses and values 2022-07-11 15:24:55 +02:00
Craig Raw 94c5920c27 prevent potential npe on qr display dialog 2022-07-11 14:13:49 +02:00
Craig Raw c2eb505bd9 add all whirlpool accounts if any one is discovered on wallet import 2022-07-07 15:18:19 +02:00
Craig Raw 930e36fa2b fix saving encrypted keystores on all wallets when changing password on child wallets 2022-07-07 09:58:24 +02:00
Craig Raw 9022438397 require non-empty password on encrypted wallet load, avoid re-requesting passphrase on bip39 wallet import, show empty passphrases as no passphrase 2022-07-05 11:45:43 +02:00
Craig Raw ba9aed5395 show wallet name in delete dialog 2022-07-05 09:22:13 +02:00
Craig Raw c780a8d944 followup 2022-06-07 15:01:00 +02:00
Craig Raw 3ed15ffefe add mac os runner 2022-06-07 13:59:30 +02:00
Craig Raw ededb107a3 check proposed wallet name against open wallets 2022-06-06 15:09:56 +02:00
Craig Raw aebf4f9d28 followup 2022-06-06 14:09:47 +02:00
Craig Raw 336d0e551b add bip47 support for bitcoin core connections 2022-06-06 13:39:38 +02:00
Craig Raw 5da9532614 add tooltip to wallet tab label on load failure 2022-05-31 09:25:00 +02:00
Craig Raw 956c2eaaaa allow mixing out to other postmix wallet accounts 2022-05-31 08:39:36 +02:00
Craig Raw 62e7c34eb5 show taproot specific sighash default in sighash dropdown, and select if appropriate 2022-05-30 16:29:09 +02:00
Craig Raw 15da62777e compile bwt for older macos 2022-05-26 12:22:23 +02:00
Craig Raw 3f2db7a199 v1.6.5 2022-05-26 11:54:36 +02:00
Craig Raw adc9905038 hide entry cell action boxes completely when not hovered over 2022-05-26 10:47:43 +02:00
Craig Raw fa82e1146b allow watch keystores to enable signing from connected hardware wallets 2022-05-26 10:22:20 +02:00
Craig Raw 33a61e3414 set lock all wallet menu command on every wallet lock 2022-05-26 09:31:38 +02:00
Craig Raw 70eda16515 fix psbt taproot internal key duplication 2022-05-25 16:18:44 +02:00
Craig Raw e2eb7d3fa9 hwi v2.1.1 2022-05-25 13:33:05 +02:00
Craig Raw 8aa0461d83 add ledger nano s plus wallet model 2022-05-24 10:44:05 +02:00
Craig Raw 0f2cf9c5bb improve delete wallet performance by reducing overwrite entropy 2022-05-24 09:03:47 +02:00
Craig Raw 2c1f7e181c when hiding empty used addresses, add previously used updated address nodes in sequence 2022-05-23 13:11:08 +02:00
Craig Raw 3555a0bd85 import gap limit from electrum wallets 2022-05-23 09:55:09 +02:00
Craig Raw d7ce58d810 when hiding empty used addresses, remove updated address nodes where address balance is zero 2022-05-23 09:40:35 +02:00
Craig Raw b0e1f6fe32 update documentation for java 17 upgrade 2022-05-23 08:55:13 +02:00
Craig Raw a324224e2a explicitly commit spinner editor value when closing dialogs 2022-05-23 08:19:48 +02:00
Craig Raw ddcb3e6f61 raise minimum for min mixes before mixing out to 2 2022-05-19 15:53:47 +02:00
Craig Raw f4259642b8 fix date sort on utxos table 2022-05-19 15:05:50 +02:00
Craig Raw db60afd13b v1.6.4 2022-05-19 13:32:33 +02:00
Craig Raw f176a2a04f add freeze utxo hyperlink to dust attack warning 2022-05-19 12:44:15 +02:00
Craig Raw 82be3a52dc show signature status on transaction tab for loaded transactions when offline 2022-05-19 11:23:40 +02:00
Craig Raw 4b2b8f653a ensure minimum relay fee rate is always equal or greater than 1 sat/vb 2022-05-19 08:50:34 +02:00
Craig Raw 555e5ecfb8 recalculate txid before copying 2022-05-18 08:44:34 +02:00
Craig Raw c0ca74ce6a add dust attack warning to utxos tab where small value txes are received on used addresses 2022-05-18 08:38:47 +02:00
Craig Raw 674498052f update gh build action and java versions 2022-05-17 12:30:22 +02:00
Craig Raw 1399b73dc2 upgrade to nightjar 0.2.33 for whirlpool startup fix 2022-05-17 11:20:17 +02:00
Craig Raw c51f3d9e66 improve validation and focus handling in integer spinners 2022-05-17 10:52:43 +02:00
Craig Raw 766a8c267f scan seed qr to bip39 and watch only keystores 2022-05-17 09:18:09 +02:00
Craig Raw 948d663fbf sign psbt from a transient scanned seed (seedqr, compactseedqr, ur:crypto-seed, ur:crypto-bip39 supported) 2022-05-17 08:04:57 +02:00
Craig Raw 66be5c43a6 fix whirlpool introduction text 2022-05-16 08:32:09 +02:00
Craig Raw e0b00513b9 make usb hw enumerate period configurable 2022-05-12 14:50:31 +02:00
Craig Raw 218761c594 disable privacy optimisation button for payjoins, improve rbf behaviour for wallet sweep txes 2022-05-12 10:57:31 +02:00
Craig Raw 5e4d6d5a78 only show mix selected button for p2wpkh wallets 2022-05-11 15:27:02 +02:00
Craig Raw b06df383dd enable max button when rbf replacement tx has only one output 2022-05-11 15:18:14 +02:00
Craig Raw 361e92c600 fix test build error 2022-05-11 13:23:44 +02:00
Craig Raw 69d0a2f96e configure aarch64 specific dependencies 2022-05-09 15:01:35 +02:00
Craig Raw bf078b2ea0 add native libraries for aarch64 2022-05-06 17:34:23 +02:00
Craig Raw b5fa8f0ee0 add delete wallet functionality, overwriting wallet file data first 2022-05-06 10:37:15 +02:00
Craig Raw 7fb230e56b upgrade to javafx 18 2022-05-05 10:54:48 +02:00
Craig Raw a53ecc4fdc Merge branch 'master' of github.com:sparrowwallet/sparrow 2022-05-05 10:19:58 +02:00
Craig Raw 3955eaaa3c upgrade to gradle 7.4.2 2022-05-05 10:19:31 +02:00
Craig Raw d1d090a12b followup 2022-05-04 15:10:15 +02:00
Craig Raw cd1509749a upgrade to hwi-2.1.0 with usb taproot signing and jade support 2022-05-04 14:33:48 +02:00
Craig Raw dd5278f442 update mix to button when wallet label is changed 2022-05-03 12:24:31 +02:00
Craig Raw 631f5d48df add note on installing wix to build windows installer 2022-05-03 11:43:22 +02:00
Craig Raw c981cf32b9 add restart in testnet/mainnet menu command 2022-05-03 09:20:30 +02:00
Craig Raw 984cabfc03 make connection toggle pulsing clearer by increasing opacity range 2022-04-29 15:54:37 +02:00
Craig Raw 8d28f8f0a9 reverse sort order of date column in utxos tab 2022-04-29 15:38:10 +02:00
Craig Raw 472fccc788 enable pasting a string into send to many spreadsheet using pre-editing cell context menu 2022-04-29 12:18:17 +02:00
Craig Raw b1e715b272 update wallet name in db on load if wallet filename is changed 2022-04-28 14:57:04 +02:00
Craig Raw 6931cf7a45 add select all button to utxos tab 2022-04-28 13:10:52 +02:00
Craig Raw 1ccfc3c042 disable lock all wallets menu item when all wallets are locked 2022-04-28 12:28:58 +02:00
Craig Raw dd1976f173 rename menu item where necessary to indicate wallet accounts are refreshed individually 2022-04-28 11:58:49 +02:00
Craig Raw eceaf40430 update confirmation status for opened unconfirmed wallet txes when mined 2022-04-28 11:22:24 +02:00
Craig Raw e565786bbc pass desired account number to keystore import description 2022-04-27 10:53:11 +02:00
Craig Raw 37a8a0a7f9 improve table sorting of utxos with the same hash 2022-04-26 16:21:13 +02:00
Craig Raw 6aa3bb2ff3 disable clear button when no utxos are selected 2022-04-26 16:03:49 +02:00
Craig Raw 516ee26ba0 remove ds store files 2022-04-26 14:58:23 +02:00
Craig Raw 1224abcd1d truncate keystore labels on caravan import 2022-04-26 14:34:45 +02:00
Craig Raw 81eda96690 v1.6.3 2022-03-31 15:11:00 +02:00
Craig Raw af9eb3cc64 add pdf export of wallet output descriptor from qr display dialog 2022-03-31 14:14:20 +02:00
Craig Raw d9bba16eb6 ensure order of unencrypted wallet tabs is retained across restarts 2022-03-31 11:42:46 +02:00
Craig Raw 58cd50f674 update bwt for bitcoin core v23 compatibility, ensure rescan when changing wallet birthday in transactions table 2022-03-31 10:38:38 +02:00
Craig Raw b2b9dbeb8d remove earn.com as fee rates source 2022-03-31 09:21:31 +02:00
Craig Raw 0469141fee use paynym.is onion address when proxy is set 2022-03-31 09:20:57 +02:00
Craig Raw b16c7345a8 allow collaborative sends for linked paynyms, support searching for custom paynyms when initiating collaborative sends 2022-03-30 18:26:41 +02:00
Craig Raw b1940e9293 fix issues updating utxo chart when mixing 2022-03-29 17:38:39 +02:00
Craig Raw 1f51f632c4 change show delay duration for help tooltips to 500ms 2022-03-29 12:10:47 +02:00
Craig Raw ba199ff11b avoid npe loading new wallets with watchlast set 2022-03-28 09:40:31 +02:00
Craig Raw 79c0f7769a indicate when a server failure occurs loading a transaction from file 2022-03-28 09:31:18 +02:00
Craig Raw 761ec0659f show error message if partial signatures do not match wallet on finalizing psbt 2022-03-27 11:00:25 +02:00
Craig Raw 5e31cdb7ac update utxo fiat balances as rates change 2022-03-26 14:15:00 +02:00
Craig Raw 468384d82a omit frozen utxos from soroban collaboration wallets 2022-03-26 11:19:30 +02:00
Craig Raw 230a4c5585 move subtabs to the left with clearer color hierarchy, always show once a multiple account wallet has been opened 2022-03-22 09:16:28 +02:00
Craig Raw 9048d341c6 disable bip47 support for taproot script type 2022-03-21 09:39:16 +02:00
Craig Raw b0f60bb671 warn if saved certificate file could not be deleted 2022-03-19 12:28:12 +02:00
Craig Raw 9c87ecd4ec v1.6.2 2022-03-17 15:20:33 +02:00
Craig Raw 5324e5fcc2 add paynym contact from followers list 2022-03-17 14:47:45 +02:00
Craig Raw c02da607e7 allow any linked paynym contact to be renamed 2022-03-17 13:11:45 +02:00
Craig Raw 281fad5970 identify and color code signatures in transaction hex witness data 2022-03-17 12:12:57 +02:00
Craig Raw 95d8201bd9 set bip47 wallet label before creating addresses 2022-03-17 10:33:47 +02:00
Craig Raw 04cb27f85e label invalid notification transactions and avoid relink attempts 2022-03-17 09:45:51 +02:00
Craig Raw a765e07c10 support linking and sending to payment codes without paynym.is 2022-03-16 16:37:08 +02:00
Craig Raw ef5cca26ea allow mix counterparty to retry listening 2022-03-14 11:47:12 +02:00
Craig Raw d86517606b make db-updater daemon thread 2022-03-13 10:56:58 +02:00
Craig Raw 9dcf3b7eea final fix for send to paynym max button issue 2022-03-11 12:54:09 +02:00
Craig Raw 689f4abfde enable create button when sending max payment to a linked paynym 2022-03-08 10:22:39 +01:00
275 changed files with 11437 additions and 1428 deletions
+25 -14
View File
@@ -7,36 +7,47 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
os: [windows-latest, ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
- name: Set up JDK 16.0.1
uses: actions/setup-java@v2
- name: Set up JDK 18.0.1
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '16.0.1'
distribution: 'temurin'
java-version: '18.0.1'
- name: Show Build Versions
run: ./gradlew -v
- name: Cache Gradle packages
uses: actions/cache@v2
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
restore-keys: ${{ runner.os }}-gradle
- name: Build with Gradle
run: ./gradlew jpackage
- name: Package zip distribution
if: ${{ runner.os == 'Windows' }}
if: ${{ runner.os == 'Windows' || runner.os == 'macOS' }}
run: ./gradlew packageZipDistribution
- name: Package tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: Sparrow Build - ${{ runner.os }}
path: |
build/jpackage/*
!build/jpackage/Sparrow/
- name: Headless build with Gradle
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true clean jpackage
- name: Package headless tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Rename Headless Artifacts
if: ${{ runner.os == 'Linux' }}
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
with:
name: Sparrow Build - ${{ runner.os }} Headless
path: |
build/jpackage/*
!build/jpackage/Sparrow/
+11 -9
View File
@@ -16,8 +16,8 @@ or for those without SSH credentials:
`git clone --recursive https://github.com/sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 16 to be installed.
The release binaries are built with [AdoptOpenJdk 16.0.1+9 Hotspot](https://adoptopenjdk.net/archive.html?variant=openjdk16&jvmVariant=hotspot).
In order to build, Sparrow requires Java 17 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:
@@ -28,13 +28,15 @@ The Sparrow binaries can be built from source using
`./gradlew jpackage`
Note that to build the Windows installer, you will need to install [WiX](https://github.com/wixtoolset/wix3/releases).
When updating to the latest HEAD
`git pull --recurse-submodules`
The release binaries are reproducible from v1.5.0 onwards (pre codesigning and installer packaging). More detailed [instructions on reproducing the binaries](docs/reproducible.md) are provided.
> A video documention of your built process uploaded to [bitcoinbinary.org](https://bitcoinbinary.org/) is appreciated. Alternatively check the site if you wish to see if someone else already verified the provided binaries.
> Video documentation of your build process uploaded to [bitcoinbinary.org](https://bitcoinbinary.org/) is appreciated. Alternatively check the site if you wish to see if someone else already verified the provided binaries.
## Running
@@ -42,7 +44,7 @@ If you prefer to run Sparrow directly from source, it can be launched from withi
`./sparrow`
Java 16 or higher must be installed.
Java 17 or higher must be installed.
## Configuration
@@ -75,11 +77,11 @@ Note that if you are connecting to an Electrum server when using testnet, that s
When not explicitly configured using the command line argument above, Sparrow stores its mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
Platform | Location
-------- | --------
OSX | ~/.sparrow
Linux | ~/.sparrow
Windows | %APPDATA%/Sparrow
| Platform | Location |
|----------| -------- |
| OSX | ~/.sparrow |
| Linux | ~/.sparrow |
| Windows | %APPDATA%/Sparrow |
Testnet, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
+42 -23
View File
@@ -1,16 +1,27 @@
import java.awt.GraphicsEnvironment
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.0.10'
id 'org-openjfx-javafxplugin'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.24.0'
id 'org.beryx.jlink' version '2.25.0'
}
def sparrowVersion = '1.6.1'
def sparrowVersion = '1.7.0'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
osName = "osx"
}
def targetName = ""
def osArch = "x64"
def releaseArch = "x86_64"
if(System.getProperty("os.arch") == "aarch64") {
osArch = "aarch64"
releaseArch = "aarch64"
targetName = "-" + osArch
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
group "com.sparrowwallet"
version "${sparrowVersion}"
@@ -29,7 +40,7 @@ tasks.withType(AbstractArchiveTask) {
}
javafx {
version = "17"
version = "18"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -45,7 +56,7 @@ dependencies {
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:2.0.206')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3')
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j'
@@ -67,13 +78,13 @@ dependencies {
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('co.nstant.in:cbor:0.9')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
}
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8") {
implementation("com.sparrowwallet:netlayer-jpms-${osName}${targetName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
@@ -92,17 +103,19 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.32')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.33')
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('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.27')
implementation('com.googlecode.lanterna:lanterna:3.1.1')
testImplementation('junit:junit:4.12')
}
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.MainApp'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
}
compileJava {
@@ -117,13 +130,13 @@ compileJava {
processResources {
doLast {
delete fileTree("$buildDir/resources/main/native").matching {
exclude "${osName}/**"
exclude "${osName}/${osArch}/**"
}
}
}
test {
jvmArgs '--add-opens=java.base/java.io=ALL-UNNAMED'
jvmArgs '--add-opens=java.base/java.io=com.google.gson'
}
run {
@@ -151,6 +164,9 @@ run {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
if(headless) {
applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
}
}
jlink {
@@ -170,7 +186,7 @@ jlink {
uses 'org.eclipse.jetty.websocket.common.RemoteEndpointFactory'
}
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
launcher {
name = 'sparrow'
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
@@ -203,6 +219,9 @@ jlink {
if(os.macOsX) {
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
if(headless) {
jvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
}
}
addExtraDependencies("javafx")
jpackage {
@@ -211,7 +230,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', '--license-file', 'LICENSE']
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']
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']
@@ -238,12 +257,13 @@ task packageZipDistribution(type: Zip) {
destinationDirectory = file("$buildDir/jpackage")
from("$buildDir/jpackage/") {
include "Sparrow/**"
include "Sparrow.app/**"
}
}
task packageTarDistribution(type: Tar) {
dependsOn removeGroupWritePermission
archiveFileName = "sparrow-${sparrowVersion}.tar.gz"
archiveFileName = "sparrow-${sparrowVersion}-${releaseArch}.tar.gz"
destinationDirectory = file("$buildDir/jpackage")
compression = Compression.GZIP
from("$buildDir/jpackage/") {
@@ -328,12 +348,12 @@ extraJavaModuleInfo {
requires('org.slf4j')
requires('com.fasterxml.jackson.databind')
}
module('bridj-0.7-20140918-3.jar', 'com.nativelibs4java.bridj', '0.7-20140918-3') {
module("bridj${targetName}-0.7-20140918-3.jar", 'com.nativelibs4java.bridj', '0.7-20140918-3') {
exports('org.bridj')
exports('org.bridj.cpp')
requires('java.logging')
}
module('webcam-capture-0.3.13-SNAPSHOT.jar', 'com.github.sarxos.webcam.capture', '0.3.13-SNAPSHOT') {
module("webcam-capture${targetName}-0.3.13-SNAPSHOT.jar", 'com.github.sarxos.webcam.capture', '0.3.13-SNAPSHOT') {
exports('com.github.sarxos.webcam')
exports('com.github.sarxos.webcam.ds.buildin')
exports('com.github.sarxos.webcam.ds.buildin.natives')
@@ -452,6 +472,8 @@ extraJavaModuleInfo {
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')
@@ -461,7 +483,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module('nightjar-0.2.32.jar', 'com.sparrowwallet.nightjar', '0.2.32') {
module('nightjar-0.2.33.jar', 'com.sparrowwallet.nightjar', '0.2.33') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -549,7 +571,7 @@ extraJavaModuleInfo {
module('jcip-annotations-1.0.jar', 'net.jcip.annotations', '1.0') {
exports('net.jcip.annotations')
}
module("netlayer-jpms-${osName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
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')
@@ -577,15 +599,12 @@ extraJavaModuleInfo {
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
requires('java.xml')
}
module('kotlin-stdlib-common-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.common', '1.5.20') {
exports('kotlin.jvm')
exports('kotlin.collections')
}
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('hamcrest-core-1.3.jar', 'org.hamcrest.core', '1.3')
}
+9
View File
@@ -4,10 +4,15 @@ 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'
}
repositories {
mavenCentral()
maven {
url "https://plugins.gradle.org/m2/"
}
}
gradlePlugin {
@@ -17,5 +22,9 @@ gradlePlugin {
id = "extra-java-module-info"
implementationClass = "org.gradle.sample.transform.javamodules.ExtraModuleInfoPlugin"
}
register("org-openjfx-javafxplugin") {
id = "org-openjfx-javafxplugin"
implementationClass = "org.openjfx.gradle.JavaFXPlugin"
}
}
}
@@ -67,6 +67,8 @@ abstract public class ExtraModuleInfoTransform implements TransformAction<ExtraM
outputs.file(originalJar);
} else if (automaticModules.containsKey(originalJarName)) {
addAutomaticModuleName(originalJar, getModuleJar(outputs, originalJar), automaticModules.get(originalJarName));
} else if(originalJarName.startsWith("kotlin-stdlib-common")) {
//ignore
} else {
throw new RuntimeException("Not a module and no mapping defined: " + originalJarName);
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2018, 2020, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import org.gradle.api.GradleException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public enum JavaFXModule {
BASE,
GRAPHICS(BASE),
CONTROLS(BASE, GRAPHICS),
FXML(BASE, GRAPHICS),
MEDIA(BASE, GRAPHICS),
SWING(BASE, GRAPHICS),
WEB(BASE, CONTROLS, GRAPHICS, MEDIA);
static final String PREFIX_MODULE = "javafx.";
private static final String PREFIX_ARTIFACT = "javafx-";
private List<JavaFXModule> dependentModules;
JavaFXModule(JavaFXModule...dependentModules) {
this.dependentModules = List.of(dependentModules);
}
public static Optional<JavaFXModule> fromModuleName(String moduleName) {
return Stream.of(JavaFXModule.values())
.filter(javaFXModule -> moduleName.equals(javaFXModule.getModuleName()))
.findFirst();
}
public String getModuleName() {
return PREFIX_MODULE + name().toLowerCase(Locale.ROOT);
}
public String getModuleJarFileName() {
return getModuleName() + ".jar";
}
public String getArtifactName() {
return PREFIX_ARTIFACT + name().toLowerCase(Locale.ROOT);
}
public boolean compareJarFileName(JavaFXPlatform platform, String jarFileName) {
Pattern p = Pattern.compile(getArtifactName() + "-.+-" + platform.getClassifier() + "\\.jar");
return p.matcher(jarFileName).matches();
}
public static Set<JavaFXModule> getJavaFXModules(List<String> moduleNames) {
validateModules(moduleNames);
return moduleNames.stream()
.map(JavaFXModule::fromModuleName)
.flatMap(Optional::stream)
.flatMap(javaFXModule -> javaFXModule.getMavenDependencies().stream())
.collect(Collectors.toSet());
}
public static void validateModules(List<String> moduleNames) {
var invalidModules = moduleNames.stream()
.filter(module -> JavaFXModule.fromModuleName(module).isEmpty())
.collect(Collectors.toList());
if (! invalidModules.isEmpty()) {
throw new GradleException("Found one or more invalid JavaFX module names: " + invalidModules);
}
}
public List<JavaFXModule> getDependentModules() {
return dependentModules;
}
public List<JavaFXModule> getMavenDependencies() {
List<JavaFXModule> dependencies = new ArrayList<>(dependentModules);
dependencies.add(0, this);
return dependencies;
}
}
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2018, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import org.gradle.api.Project;
import org.gradle.api.artifacts.repositories.FlatDirectoryArtifactRepository;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.openjfx.gradle.JavaFXModule.PREFIX_MODULE;
public class JavaFXOptions {
private static final String MAVEN_JAVAFX_ARTIFACT_GROUP_ID = "org.openjfx";
private static final String JAVAFX_SDK_LIB_FOLDER = "lib";
private final Project project;
private final JavaFXPlatform platform;
private String version = "16";
private String sdk;
private String configuration = "implementation";
private String lastUpdatedConfiguration;
private List<String> modules = new ArrayList<>();
private FlatDirectoryArtifactRepository customSDKArtifactRepository;
public JavaFXOptions(Project project) {
this.project = project;
this.platform = JavaFXPlatform.detect(project);
}
public JavaFXPlatform getPlatform() {
return platform;
}
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
updateJavaFXDependencies();
}
/**
* If set, the JavaFX modules will be taken from this local
* repository, and not from Maven Central
* @param sdk, the path to the local JavaFX SDK folder
*/
public void setSdk(String sdk) {
this.sdk = sdk;
updateJavaFXDependencies();
}
public String getSdk() {
return sdk;
}
/** Set the configuration name for dependencies, e.g.
* 'implementation', 'compileOnly' etc.
* @param configuration The configuration name for dependencies
*/
public void setConfiguration(String configuration) {
this.configuration = configuration;
updateJavaFXDependencies();
}
public String getConfiguration() {
return configuration;
}
public List<String> getModules() {
return modules;
}
public void setModules(List<String> modules) {
this.modules = modules;
updateJavaFXDependencies();
}
public void modules(String...moduleNames) {
setModules(List.of(moduleNames));
}
private void updateJavaFXDependencies() {
clearJavaFXDependencies();
String configuration = getConfiguration();
JavaFXModule.getJavaFXModules(this.modules).stream()
.sorted()
.forEach(javaFXModule -> {
if (customSDKArtifactRepository != null) {
project.getDependencies().add(configuration, Map.of("name", javaFXModule.getModuleName()));
} else {
project.getDependencies().add(configuration,
String.format("%s:%s:%s:%s", MAVEN_JAVAFX_ARTIFACT_GROUP_ID, javaFXModule.getArtifactName(),
getVersion(), getPlatform().getClassifier()));
}
});
lastUpdatedConfiguration = configuration;
}
private void clearJavaFXDependencies() {
if (customSDKArtifactRepository != null) {
project.getRepositories().remove(customSDKArtifactRepository);
customSDKArtifactRepository = null;
}
if (sdk != null && ! sdk.isEmpty()) {
Map<String, String> dirs = new HashMap<>();
dirs.put("name", "customSDKArtifactRepository");
if (sdk.endsWith(File.separator)) {
dirs.put("dirs", sdk + JAVAFX_SDK_LIB_FOLDER);
} else {
dirs.put("dirs", sdk + File.separator + JAVAFX_SDK_LIB_FOLDER);
}
customSDKArtifactRepository = project.getRepositories().flatDir(dirs);
}
if (lastUpdatedConfiguration == null) {
return;
}
var configuration = project.getConfigurations().findByName(lastUpdatedConfiguration);
if (configuration != null) {
if (customSDKArtifactRepository != null) {
configuration.getDependencies()
.removeIf(dependency -> dependency.getName().startsWith(PREFIX_MODULE));
}
configuration.getDependencies()
.removeIf(dependency -> MAVEN_JAVAFX_ARTIFACT_GROUP_ID.equals(dependency.getGroup()));
}
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2018, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetector;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import java.awt.*;
import java.util.Arrays;
import java.util.stream.Collectors;
public enum JavaFXPlatform {
LINUX("linux", "linux-x86_64"),
LINUX_MONOCLE("linux-monocle", "linux-x86_64-monocle"),
LINUX_AARCH64("linux-aarch64", "linux-aarch_64"),
LINUX_AARCH64_MONOCLE("linux-aarch64-monocle", "linux-aarch_64-monocle"),
WINDOWS("win", "windows-x86_64"),
WINDOWS_MONOCLE("win-monocle", "windows-x86_64-monocle"),
OSX("mac", "osx-x86_64"),
OSX_MONOCLE("mac-monocle", "osx-x86_64-monocle"),
OSX_AARCH64("mac-aarch64", "osx-aarch_64"),
OSX_AARCH64_MONOCLE("mac-aarch64-monocle", "osx-aarch_64-monocle");
private final String classifier;
private final String osDetectorClassifier;
JavaFXPlatform( String classifier, String osDetectorClassifier ) {
this.classifier = classifier;
this.osDetectorClassifier = osDetectorClassifier;
}
public String getClassifier() {
return classifier;
}
public static JavaFXPlatform detect(Project project) {
String osClassifier = project.getExtensions().getByType(OsDetector.class).getClassifier();
if("true".equals(System.getProperty("java.awt.headless"))) {
osClassifier += "-monocle";
}
for ( JavaFXPlatform platform: values()) {
if ( platform.osDetectorClassifier.equals(osClassifier)) {
return platform;
}
}
String supportedPlatforms = Arrays.stream(values())
.map(p->p.osDetectorClassifier)
.collect(Collectors.joining("', '", "'", "'"));
throw new GradleException(
String.format(
"Unsupported JavaFX platform found: '%s'! " +
"This plugin is designed to work on supported platforms only." +
"Current supported platforms are %s.", osClassifier, supportedPlatforms )
);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2018, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetectorPlugin;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.javamodularity.moduleplugin.ModuleSystemPlugin;
import org.openjfx.gradle.tasks.ExecTask;
public class JavaFXPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().apply(OsDetectorPlugin.class);
project.getPlugins().apply(ModuleSystemPlugin.class);
project.getExtensions().create("javafx", JavaFXOptions.class, project);
project.getTasks().create("configJavafxRun", ExecTask.class, project);
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2019, 2021, Gluon
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* * Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.openjfx.gradle.tasks;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.api.file.FileCollection;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.ApplicationPlugin;
import org.gradle.api.tasks.JavaExec;
import org.gradle.api.tasks.TaskAction;
import org.javamodularity.moduleplugin.extensions.RunModuleOptions;
import org.openjfx.gradle.JavaFXModule;
import org.openjfx.gradle.JavaFXOptions;
import org.openjfx.gradle.JavaFXPlatform;
import javax.inject.Inject;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TreeSet;
public class ExecTask extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(ExecTask.class);
private final Project project;
private JavaExec execTask;
@Inject
public ExecTask(Project project) {
this.project = project;
project.getPluginManager().withPlugin(ApplicationPlugin.APPLICATION_PLUGIN_NAME, e -> {
execTask = (JavaExec) project.getTasks().findByName(ApplicationPlugin.TASK_RUN_NAME);
if (execTask != null) {
execTask.dependsOn(this);
} else {
throw new GradleException("Run task not found.");
}
});
}
@TaskAction
public void action() {
if (execTask != null) {
JavaFXOptions javaFXOptions = project.getExtensions().getByType(JavaFXOptions.class);
JavaFXModule.validateModules(javaFXOptions.getModules());
var definedJavaFXModuleNames = new TreeSet<>(javaFXOptions.getModules());
if (!definedJavaFXModuleNames.isEmpty()) {
RunModuleOptions moduleOptions = execTask.getExtensions().findByType(RunModuleOptions.class);
final FileCollection classpathWithoutJavaFXJars = execTask.getClasspath().filter(
jar -> Arrays.stream(JavaFXModule.values()).noneMatch(javaFXModule -> jar.getName().contains(javaFXModule.getArtifactName()))
);
final FileCollection javaFXPlatformJars = execTask.getClasspath().filter(jar -> isJavaFXJar(jar, javaFXOptions.getPlatform()));
if (moduleOptions != null) {
LOGGER.info("Modular JavaFX application found");
// Remove empty JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
definedJavaFXModuleNames.forEach(javaFXModule -> moduleOptions.getAddModules().add(javaFXModule));
} else {
LOGGER.info("Non-modular JavaFX application found");
// Remove all JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars);
var javaFXModuleJvmArgs = List.of("--module-path", javaFXPlatformJars.getAsPath());
var jvmArgs = new ArrayList<String>();
jvmArgs.add("--add-modules");
jvmArgs.add(String.join(",", definedJavaFXModuleNames));
List<String> execJvmArgs = execTask.getJvmArgs();
if (execJvmArgs != null) {
jvmArgs.addAll(execJvmArgs);
}
jvmArgs.addAll(javaFXModuleJvmArgs);
execTask.setJvmArgs(jvmArgs);
}
}
} else {
throw new GradleException("Run task not found. Please, make sure the Application plugin is applied");
}
}
private static boolean isJavaFXJar(File jar, JavaFXPlatform platform) {
return jar.isFile() &&
Arrays.stream(JavaFXModule.values()).anyMatch(javaFXModule ->
javaFXModule.compareJarFileName(platform, jar.getName()) ||
javaFXModule.getModuleJarFileName().equals(jar.getName()));
}
}
+59 -26
View File
@@ -1,58 +1,88 @@
## Reproducible builds
# Reproducible builds
Reproducibility is a goal of the Sparrow Wallet project.
Reproducibility is a goal of the Sparrow Wallet project.
As of v1.5.0 and later, it is possible to recreate the exact binaries in the Github releases (specifically, the contents of the `.tar.gz` and `.zip` files).
Due to minor variances, it is not yet possible to reproduce the installer packages (`.deb`, `.rpm` and `.exe`).
In addition, the OSX binary is code signed and thus can't be directly reproduced yet.
In addition, the OSX binary is code signed and thus can't be directly reproduced yet.
Work on resolving both of these issues is ongoing.
### Reproducing a release
## Reproducing a release
#### Install Java
### Install Java
Because Sparrow bundles a Java runtime in the release binaries, it is essential to have the same version of Java installed when creating the release.
For v1.5.0 and later, this is AdoptOpenJdk jdk-16.0.1+9 Hotspot.
It is available for all supported platforms from the [AdoptOpenJdk site](https://adoptopenjdk.net/archive.html?variant=openjdk16&jvmVariant=hotspot).
For v1.6.6 and later, this is Eclipse Temurin 18.0.1+10.
#### Java from Adoptium github repo
It is available for all supported platforms from [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10).
For reference, the downloads are as follows:
- [Linux x64](https://github.com/AdoptOpenJDK/openjdk16-binaries/releases/download/jdk-16.0.1%2B9/OpenJDK16U-jdk_x64_linux_hotspot_16.0.1_9.tar.gz)
- [MacOS x64](https://github.com/AdoptOpenJDK/openjdk16-binaries/releases/download/jdk-16.0.1%2B9/OpenJDK16U-jdk_x64_mac_hotspot_16.0.1_9.tar.gz)
- [Windows x64](https://github.com/AdoptOpenJDK/openjdk16-binaries/releases/download/jdk-16.0.1%2B9/OpenJDK16U-jdk_x64_windows_hotspot_16.0.1_9.zip)
- [Linux x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_linux_hotspot_18.0.1_10.tar.gz)
- [MacOS x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_mac_hotspot_18.0.1_10.tar.gz)
- [MacOS aarch64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_aarch64_mac_hotspot_18.0.1_10.tar.gz)
- [Windows x64](https://github.com/adoptium/temurin18-binaries/releases/download/jdk-18.0.1%2B10/OpenJDK18U-jdk_x64_windows_hotspot_18.0.1_10.zip)
#### Java from Adoptium deb repo
It is also possible to install via a package manager on *nix systems. For example, on Debian/Ubuntu systems:
```shell
sudo apt-get install -y wget apt-transport-https gnupg
wget https://adoptopenjdk.jfrog.io/adoptopenjdk/api/gpg/key/public
gpg --no-default-keyring --keyring ./adoptopenjdk-keyring.gpg --import public
gpg --no-default-keyring --keyring ./adoptopenjdk-keyring.gpg --export --output adoptopenjdk-archive-keyring.gpg
rm adoptopenjdk-keyring.gpg
sudo mv adoptopenjdk-archive-keyring.gpg /usr/share/keyrings
echo "deb [signed-by=/usr/share/keyrings/adoptopenjdk-archive-keyring.gpg] https://adoptopenjdk.jfrog.io/adoptopenjdk/deb $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/adoptopenjdk.list
sudo apt update -y
sudo apt-get install -y adoptopenjdk-16-hotspot=16.0.1+9-3
- Install dependencies:
```sh
sudo apt-get install -y wget curl apt-transport-https gnupg
```
Download Adoptium public PGP key:
```sh
curl --tlsv1.2 --proto =https --location -o adoptium.asc https://packages.adoptium.net/artifactory/api/gpg/key/public
```
Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
```
gpg --import --import-options show-only adoptium.asc
```
If key doesn't match, do not procede.
Add Adoptium PGP key to a the keyring shared folder:
```sh
sudo cp adoptium.asc /usr/share/keyrings/
```
Add Adoptium debian repository:
```sh
echo "deb [signed-by=/usr/share/keyrings/adoptium.asc] https://packages.adoptium.net/artifactory/deb $(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
```
Update cache, install the desired temurin version and configure java to be linked to this same version:
```
sudo apt update -y
sudo apt-get install -y temurin-18-jdk=18.0.1+10
sudo update-alternatives --config java
```
#### Java from SDK
A alternative option for all platforms is to use the [sdkman.io](https://sdkman.io/) package manager ([Git Bash for Windows](https://git-scm.com/download/win) is a good choice on that platform).
See the installation [instructions here](https://sdkman.io/install).
Once installed, run
```shell
sdk install java 16.0.1.hs-adpt
sdk install java 18.0.1-tem
```
#### Other requirements
### Other requirements
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
```shell
sudo apt install -y rpm fakeroot binutils
```
#### Building the binaries
### Building the binaries
The project can cloned for a specific release tag as follows:
```shell
GIT_TAG="1.5.2"
git clone --recursive --branch "${GIT_TAG}" git@github.com:sparrowwallet/sparrow.git
GIT_TAG="1.7.0"
git clone --recursive --branch "${GIT_TAG}" https://github.com/sparrowwallet/sparrow.git
```
Thereafter, building should be straightforward:
@@ -64,7 +94,9 @@ cd sparrow
The binaries (and installers) will be placed in the `build/jpackage` folder.
#### Verifying the binaries are identical
### Verifying the binaries are identical
Verify the built binaries against the released binaries on https://github.com/sparrowwallet/sparrow/releases.
Note that you will be verifying the files in the `build/jpackage/Sparrow` folder against either the `.tar.gz` or `.zip` releases.
Download either of these depending on your platform and extract the contents to a folder (in the following example, `/tmp`).
@@ -76,3 +108,4 @@ diff -r build/jpackage/Sparrow /tmp/Sparrow
This command should have no output indicating that the two folders (and all their contents) are identical.
If there is output, please open an issue with detailed instructions to reproduce, including build system platform.
+1 -1
Submodule drongo updated: 04631be8c1...7c34ec7c3b
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+165 -110
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
#!/bin/sh
#
# Copyright 2015 the original author or authors.
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -17,67 +17,101 @@
#
##############################################################################
##
## Gradle start up script for UN*X
##
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# 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
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
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"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
MAX_FD=maximum
warn () {
echo "$*"
}
} >&2
die () {
echo
echo "$*"
echo
exit 1
}
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
@@ -106,80 +140,101 @@ location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
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.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+8 -6
View File
@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
+2
View File
@@ -0,0 +1,2 @@
mime-type=x-scheme-handler/auth47
description=Auth47 Authentication URI
+2
View File
@@ -0,0 +1,2 @@
mime-type=x-scheme-handler/lightning
description=LNURL URI
@@ -6,4 +6,4 @@ Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
+17 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.6.1</string>
<string>1.7.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -45,6 +45,22 @@
<string>bitcoin</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.auth47</string>
<key>CFBundleURLSchemes</key>
<array>
<string>auth47</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.lightning</string>
<key>CFBundleURLSchemes</key>
<array>
<string>lightning</string>
</array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
+20
View File
@@ -77,6 +77,16 @@
<DirectoryRef Id="TARGETDIR">
<Component Id="RegistryEntries" Guid="{206C911C-56EF-44B8-9257-5FD214427965}">
<RegistryKey Root="HKCR" Key="auth47" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Auth47 Authentication URI"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR" Key="bitcoin" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Bitcoin Payment URL"/>
@@ -87,6 +97,16 @@
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR" Key="lightning" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:LNURL URI"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
</Component>
</DirectoryRef>
@@ -12,7 +12,7 @@ public class AboutController {
private Label title;
public void initializeView() {
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION + MainApp.APP_VERSION_SUFFIX);
title.setText(SparrowWallet.APP_NAME + " " + SparrowWallet.APP_VERSION + SparrowWallet.APP_VERSION_SUFFIX);
}
public void setStage(Stage stage) {
@@ -1,13 +1,11 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.google.common.base.Charsets;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteSource;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
@@ -37,7 +35,6 @@ import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.WalletController;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import de.codecentric.centerdevice.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
@@ -90,6 +87,7 @@ public class AppController implements Initializable {
public static final String LOADING_TRANSACTIONS_MESSAGE = "Loading wallet, select Transactions tab to view...";
public static final String CONNECTION_FAILED_PREFIX = "Connection failed: ";
public static final String TRYING_ANOTHER_SERVER_MESSAGE = "trying another server...";
public static final String JPACKAGE_APP_PATH = "jpackage.app-path";
@FXML
private MenuItem saveTransaction;
@@ -109,6 +107,12 @@ public class AppController implements Initializable {
@FXML
private MenuItem exportWallet;
@FXML
private MenuItem deleteWallet;
@FXML
private MenuItem closeTab;
@FXML
private Menu fileMenu;
@@ -127,6 +131,9 @@ public class AppController implements Initializable {
@FXML
private ToggleGroup bitcoinUnit;
@FXML
private ToggleGroup unitFormat;
@FXML
private ToggleGroup theme;
@@ -156,6 +163,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem lockWallet;
@FXML
private MenuItem lockAllWallets;
@FXML
private MenuItem searchWallet;
@@ -174,10 +184,16 @@ public class AppController implements Initializable {
@FXML
private MenuItem showPayNym;
@FXML
private Menu switchServer;
@FXML
private CheckMenuItem preventSleep;
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
@FXML
private MenuItem restart;
@FXML
private StackPane rootStack;
@@ -196,6 +212,8 @@ public class AppController implements Initializable {
private Tab previouslySelectedTab;
private boolean subTabsVisible;
private final Set<Wallet> loadingWallets = new LinkedHashSet<>();
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
@@ -284,11 +302,13 @@ public class AppController implements Initializable {
EventManager.get().post(new TransactionTabsClosedEvent(closedTransactionTabs));
}
closeTab.setDisable(tabs.getTabs().isEmpty());
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage)tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
exportWallet.setDisable(true);
}
}
});
@@ -307,6 +327,15 @@ public class AppController implements Initializable {
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
format = UnitFormat.DOT;
Config.get().setUnitFormat(format);
}
final UnitFormat selectedFormat = format;
Optional<Toggle> selectedFormatToggle = unitFormat.getToggles().stream().filter(toggle -> selectedFormat.equals(toggle.getUserData())).findFirst();
selectedFormatToggle.ifPresent(toggle -> unitFormat.selectToggle(toggle));
Theme configTheme = Config.get().getTheme();
if(configTheme == null) {
configTheme = Theme.LIGHT;
@@ -329,6 +358,8 @@ public class AppController implements Initializable {
showLoadingLog.selectedProperty().bindBidirectional(showLoadingLogProperty);
preventSleepProperty.set(Config.get().isPreventSleep());
preventSleep.selectedProperty().bindBidirectional(preventSleepProperty);
restart.setText("Restart in " + (Network.get() == Network.MAINNET ? Network.TESTNET.toDisplayString() : Network.MAINNET.toDisplayString()));
restart.setVisible(System.getProperty(JPACKAGE_APP_PATH) != null);
saveTransaction.setDisable(true);
showTransaction.visibleProperty().bind(Bindings.and(saveTransaction.visibleProperty(), saveTransaction.disableProperty().not()));
@@ -337,6 +368,8 @@ public class AppController implements Initializable {
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
showPSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
exportWallet.setDisable(true);
deleteWallet.disableProperty().bind(exportWallet.disableProperty());
closeTab.setDisable(true);
lockWallet.setDisable(true);
searchWallet.disableProperty().bind(exportWallet.disableProperty());
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
@@ -345,10 +378,10 @@ public class AppController implements Initializable {
showPayNym.setDisable(true);
findMixingPartner.setDisable(true);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
showPayNym.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !getSelectedWalletForm().getWallet().hasPaymentCode() || !newValue);
findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
configureSwitchServer();
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
serverToggle.setDisable(Config.get().getServerType() == null);
@@ -359,6 +392,7 @@ public class AppController implements Initializable {
});
openTransactionIdItem.disableProperty().bind(onlineProperty().not());
setNetworkLabel();
}
private void setPlatformApplicationMenu() {
@@ -368,10 +402,10 @@ public class AppController implements Initializable {
MenuItem preferences = new MenuItem("Preferences...");
preferences.setOnAction(this::openPreferences);
preferences.setAccelerator(new KeyCodeCombination(KeyCode.COMMA, KeyCombination.META_DOWN));
Menu defaultApplicationMenu = new Menu("Apple", null, tk.createAboutMenuItem(MainApp.APP_NAME, getAboutStage()), new SeparatorMenuItem(),
Menu defaultApplicationMenu = new Menu("Apple", null, tk.createAboutMenuItem(SparrowWallet.APP_NAME, getAboutStage()), new SeparatorMenuItem(),
preferences, new SeparatorMenuItem(),
tk.createHideMenuItem(MainApp.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
tk.createQuitMenuItem(MainApp.APP_NAME));
tk.createHideMenuItem(SparrowWallet.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
tk.createQuitMenuItem(SparrowWallet.APP_NAME));
tk.setApplicationMenu(defaultApplicationMenu);
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
@@ -386,12 +420,27 @@ public class AppController implements Initializable {
}
}
private void setNetworkLabel() {
if(Network.get() != Network.MAINNET) {
Platform.runLater(() -> {
StackPane tabBackground = (StackPane)tabs.lookup(".tab-header-background");
if(tabBackground != null) {
HBox hBox = new HBox();
Label label = new Label(Network.get().toDisplayString());
label.setPadding(new Insets(0, 10, 0, 0));
hBox.getChildren().add(label);
hBox.setAlignment(Pos.CENTER_RIGHT);
tabBackground.getChildren().add(hBox);
}
});
}
}
public void showIntroduction(ActionEvent event) {
WelcomeDialog welcomeDialog = new WelcomeDialog();
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
preferencesDialog.showAndWait();
openPreferences(PreferenceGroup.SERVER);
}
}
@@ -408,6 +457,10 @@ public class AppController implements Initializable {
}
}
public void openSupport(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/opensupport");
}
public void submitBugReport(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
}
@@ -428,7 +481,7 @@ public class AppController implements Initializable {
}
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle("About " + MainApp.APP_NAME);
stage.setTitle("About " + SparrowWallet.APP_NAME);
stage.initOwner(tabs.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
@@ -688,7 +741,7 @@ public class AppController implements Initializable {
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!asText && !file.getName().toLowerCase().endsWith(".psbt")) {
if(!asText && !file.getName().toLowerCase(Locale.ROOT).endsWith(".psbt")) {
file = new File(file.getAbsolutePath() + ".psbt");
}
@@ -781,6 +834,10 @@ public class AppController implements Initializable {
}
}
public void deleteWallet(ActionEvent event) {
deleteWallet(getSelectedWalletForm());
}
public void closeTab(ActionEvent event) {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
}
@@ -829,12 +886,44 @@ public class AppController implements Initializable {
EventManager.get().post(new BitcoinUnitChangedEvent(unit));
}
public void setUnitFormat(ActionEvent event) {
MenuItem item = (MenuItem)event.getSource();
UnitFormat format = (UnitFormat)item.getUserData();
Config.get().setUnitFormat(format);
EventManager.get().post(new UnitFormatChangedEvent(format));
}
public void preventSleep(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setPreventSleep(item.isSelected());
AppServices.get().setPreventSleep(item.isSelected());
}
public void restart(ActionEvent event) {
if(System.getProperty(JPACKAGE_APP_PATH) == null) {
throw new IllegalStateException("Property " + JPACKAGE_APP_PATH + " is not present");
}
Args args = new Args();
ProcessHandle.current().info().arguments().ifPresent(argv -> {
JCommander jCommander = JCommander.newBuilder().addObject(args).acceptUnknownOptions(true).build();
jCommander.parse(argv);
});
args.network = (Network.get() == Network.MAINNET ? Network.TESTNET : Network.MAINNET);
try {
List<String> cmd = new ArrayList<>();
cmd.add(System.getProperty(JPACKAGE_APP_PATH));
cmd.addAll(args.toParams());
final ProcessBuilder builder = new ProcessBuilder(cmd);
builder.start();
quit(event);
} catch(Exception e) {
log.error("Error restarting application", e);
}
}
public void openFile(File file) {
if(isWalletFile(file)) {
openWalletFile(file, true);
@@ -851,7 +940,7 @@ public class AppController implements Initializable {
private String getServerToggleTooltipText(Integer currentBlockHeight) {
if(AppServices.isConnected()) {
return "Connected to " + Config.get().getServerAddress() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") +
return "Connected to " + Config.get().getServerDisplayName() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") +
(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? "\nWarning! You are connected to a public server and sharing your transaction data with it.\nFor better privacy, consider using your own Bitcoin Core node or private Electrum server." : "");
}
@@ -892,6 +981,7 @@ public class AppController implements Initializable {
Storage storage = new Storage(file);
if(!storage.isEncrypted()) {
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage);
loadWalletService.setExecutor(Storage.LoadWalletService.getSingleThreadedExecutor());
loadWalletService.setOnSucceeded(workerStateEvent -> {
WalletAndKey walletAndKey = loadWalletService.getValue();
openWallet(storage, walletAndKey, this, forceSameWindow);
@@ -944,7 +1034,7 @@ public class AppController implements Initializable {
} catch(Exception e) {
if(e instanceof IOException && e.getMessage().startsWith("The process cannot access the file because another process has locked")) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", "The wallet file is locked. Is another instance of " + MainApp.APP_NAME + " already running?");
showErrorDialog("Error Opening Wallet", "The wallet file is locked. Is another instance of " + SparrowWallet.APP_NAME + " already running?");
} else if(!attemptImportWallet(file, null)) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage() == null ? "Unsupported file format" : e.getMessage());
@@ -954,8 +1044,7 @@ public class AppController implements Initializable {
private void openWallet(Storage storage, WalletAndKey walletAndKey, AppController appController, boolean forceSameWindow) {
try {
checkWalletNetwork(walletAndKey.getWallet());
restorePublicKeysFromSeed(storage, walletAndKey.getWallet(), walletAndKey.getKey());
storage.restorePublicKeysFromSeed(walletAndKey.getWallet(), walletAndKey.getKey());
if(!walletAndKey.getWallet().isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
@@ -963,7 +1052,9 @@ public class AppController implements Initializable {
for(Map.Entry<WalletAndKey, Storage> entry : walletAndKey.getChildWallets().entrySet()) {
openWallet(entry.getValue(), entry.getKey(), walletAppController, true);
}
Platform.runLater(() -> selectTab(walletAndKey.getWallet()));
if(walletAndKey.getWallet().isMasterWallet()) {
Platform.runLater(() -> selectTab(walletAndKey.getWallet()));
}
} catch(Exception e) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage());
@@ -972,92 +1063,6 @@ public class AppController implements Initializable {
}
}
private void checkWalletNetwork(Wallet wallet) {
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
}
}
private void restorePublicKeysFromSeed(Storage storage, Wallet wallet, Key key) throws MnemonicException {
if(wallet.containsMasterPrivateKeys()) {
//Derive xpub and master fingerprint from seed, potentially with passphrase
Wallet copy = wallet.copy();
for(int i = 0; i < copy.getKeystores().size(); i++) {
Keystore copyKeystore = copy.getKeystores().get(i);
if(copyKeystore.hasSeed()) {
if(copyKeystore.getSeed().needsPassphrase()) {
if(!wallet.isMasterWallet() && wallet.getMasterWallet().getKeystores().size() == copy.getKeystores().size() && wallet.getMasterWallet().getKeystores().get(i).hasSeed()) {
copyKeystore.getSeed().setPassphrase(wallet.getMasterWallet().getKeystores().get(i).getSeed().getPassphrase());
} else {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(wallet.getFullDisplayName(), copyKeystore);
Optional<String> optionalPassphrase = passphraseDialog.showAndWait();
if(optionalPassphrase.isPresent()) {
copyKeystore.getSeed().setPassphrase(optionalPassphrase.get());
} else {
return;
}
}
} else {
copyKeystore.getSeed().setPassphrase("");
}
}
}
if(wallet.isEncrypted()) {
if(key == null) {
throw new IllegalStateException("Wallet was not encrypted, but seed is");
}
copy.decrypt(key);
}
if(wallet.isWhirlpoolMasterWallet()) {
String walletId = storage.getWalletId(wallet);
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
whirlpool.setScode(wallet.getMasterMixConfig().getScode());
whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
if(standardAccount != null && standardAccount.getMinimumGapLimit() != null && wallet.gapLimit() == null) {
wallet.setGapLimit(standardAccount.getMinimumGapLimit());
}
for(int i = 0; i < wallet.getKeystores().size(); i++) {
Keystore keystore = wallet.getKeystores().get(i);
if(keystore.hasSeed()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromSeed(copyKeystore.getSeed(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getSeed().clear();
} else if(keystore.hasMasterPrivateExtendedKey()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.setBip47ExtendedPrivateKey(derivedKeystore.getBip47ExtendedPrivateKey());
copyKeystore.getMasterPrivateKey().clear();
}
}
}
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
try {
Keystore keystore = childWallet.getKeystores().get(0);
keystore.setBip47ExtendedPrivateKey(wallet.getKeystores().get(0).getBip47ExtendedPrivateKey());
} catch(Exception e) {
log.error("Cannot prepare BIP47 keystore", e);
}
}
}
}
public void importWallet(ActionEvent event) {
WalletImportDialog dlg = new WalletImportDialog();
Optional<Wallet> optionalWallet = dlg.showAndWait();
@@ -1077,6 +1082,10 @@ public class AppController implements Initializable {
new KeystoneSinglesig(), new KeystoneMultisig(),
new CaravanMultisig());
for(WalletImport importer : walletImporters) {
if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) {
continue;
}
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
@@ -1153,14 +1162,12 @@ public class AppController implements Initializable {
try {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(storage, wallet, null);
storage.restorePublicKeysFromSeed(wallet, null);
addWalletTabOrWindow(storage, wallet, false);
for(Wallet childWallet : wallet.getChildWallets()) {
storage.saveWallet(childWallet);
checkWalletNetwork(childWallet);
restorePublicKeysFromSeed(storage, childWallet, null);
storage.restorePublicKeysFromSeed(childWallet, null);
addWalletTabOrWindow(storage, childWallet, false);
}
Platform.runLater(() -> selectTab(wallet));
@@ -1180,8 +1187,7 @@ public class AppController implements Initializable {
wallet.encrypt(key);
storage.setEncryptionPubKey(encryptionPubKey);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(storage, wallet, key);
storage.restorePublicKeysFromSeed(wallet, key);
addWalletTabOrWindow(storage, wallet, false);
for(Wallet childWallet : wallet.getChildWallets()) {
@@ -1189,8 +1195,7 @@ public class AppController implements Initializable {
childWallet.encrypt(key);
}
storage.saveWallet(childWallet);
checkWalletNetwork(childWallet);
restorePublicKeysFromSeed(storage, childWallet, key);
storage.restorePublicKeysFromSeed(childWallet, key);
addWalletTabOrWindow(storage, childWallet, false);
}
Platform.runLater(() -> selectTab(wallet));
@@ -1225,13 +1230,17 @@ public class AppController implements Initializable {
}
public void openPreferences(ActionEvent event) {
PreferencesDialog preferencesDialog = new PreferencesDialog();
preferencesDialog.showAndWait();
openPreferences(PreferenceGroup.GENERAL);
}
public void openServerPreferences(ActionEvent event) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
openPreferences(PreferenceGroup.SERVER);
}
private void openPreferences(PreferenceGroup preferenceGroup) {
PreferencesDialog preferencesDialog = new PreferencesDialog(preferenceGroup);
preferencesDialog.showAndWait();
configureSwitchServer();
}
public void signVerifyMessage(ActionEvent event) {
@@ -1380,6 +1389,17 @@ public class AppController implements Initializable {
}
}
private boolean allWalletsLocked(Wallet lockingWallet) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData walletTabData && walletTabData.getWallet() != lockingWallet && !walletTabData.getWalletForm().isLocked()) {
return false;
}
}
return true;
}
public void searchWallet(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
if(selectedTab != null) {
@@ -1459,8 +1479,8 @@ public class AppController implements Initializable {
});
TabPane subTabs = new TabPane();
subTabs.setSide(Side.RIGHT);
setSubTabsVisible(subTabs, false);
subTabs.setSide(Side.LEFT);
setSubTabsVisible(subTabs, areSubTabsVisible());
subTabs.rotateGraphicProperty().set(true);
tab.setContent(subTabs);
@@ -1469,7 +1489,7 @@ public class AppController implements Initializable {
tab.setUserData(tabData);
tab.setContextMenu(getTabContextMenu(tab));
walletForm.lockedProperty().addListener((observable, oldValue, newValue) -> {
setSubTabsVisible(subTabs, !newValue && subTabs.getTabs().size() > 1);
setSubTabsVisible(subTabs, !newValue && areSubTabsVisible());
});
subTabs.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedTab) -> {
@@ -1528,6 +1548,30 @@ public class AppController implements Initializable {
}
}
private void setSubTabsVisible(boolean visible) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData instanceof WalletTabData) {
setSubTabsVisible((TabPane)tab.getContent(), visible);
}
}
}
private boolean areSubTabsVisible() {
if(subTabsVisible) {
return true;
}
for(Wallet wallet : AppServices.get().getOpenWallets().keySet()) {
if(wallet.getChildWallets().stream().anyMatch(childWallet -> !childWallet.isNested())) {
subTabsVisible = true;
return true;
}
}
return false;
}
public WalletForm addWalletSubTab(TabPane subTabs, Storage storage, Wallet wallet) {
try {
Tab subTab = new Tab();
@@ -1646,6 +1690,10 @@ public class AppController implements Initializable {
}
}
private void addTransactionTab(Transaction transaction, TransactionView initialView, Integer initialIndex) {
addTransactionTab(null, null, transaction, null, null, initialView, initialIndex);
}
private void addTransactionTab(BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
addTransactionTab(null, null, blockTransaction.getTransaction(), null, blockTransaction, initialView, initialIndex);
}
@@ -1762,6 +1810,27 @@ public class AppController implements Initializable {
contextMenu.getItems().addAll(lock);
}
MenuItem moveRight = new MenuItem("Move Right");
moveRight.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index + 1, tab);
tabs.getSelectionModel().select(tab);
});
MenuItem moveLeft = new MenuItem("Move Left");
moveLeft.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index - 1, tab);
tabs.getSelectionModel().select(tab);
});
contextMenu.getItems().addAll(moveRight, moveLeft);
contextMenu.setOnShowing(event -> {
moveRight.setDisable(tabs.getTabs().indexOf(tab) == tabs.getTabs().size() - 1);
moveLeft.setDisable(tabs.getTabs().indexOf(tab) == 0);
});
MenuItem close = new MenuItem("Close");
close.setOnAction(event -> {
tabs.getTabs().remove(tab);
@@ -1779,10 +1848,69 @@ public class AppController implements Initializable {
tabs.getTabs().removeAll(tabs.getTabs());
});
contextMenu.getItems().addAll(close, closeOthers, closeAll);
contextMenu.getItems().addAll(new SeparatorMenuItem(), close, closeOthers, closeAll);
if(tab.getUserData() instanceof WalletTabData) {
MenuItem delete = new MenuItem("Delete...");
delete.setOnAction(event -> {
deleteWallet(getSelectedWalletForm());
});
contextMenu.getItems().addAll(new SeparatorMenuItem(), delete);
}
return contextMenu;
}
private void deleteWallet(WalletForm selectedWalletForm) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete " + selectedWalletForm.getWallet().getMasterName() + "?", "The wallet file and any backups will be deleted. Are you sure?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
Storage storage = selectedWalletForm.getStorage();
if(selectedWalletForm.getMasterWallet().isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(selectedWalletForm.getWallet().getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
try {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
deleteStorage(storage);
} finally {
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> deleteWallet(getSelectedWalletForm()));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
deleteStorage(storage);
}
}
}
private void deleteStorage(Storage storage) {
if(storage.isClosed()) {
Platform.runLater(storage::delete);
} else {
Platform.runLater(() -> deleteStorage(storage));
}
}
private ContextMenu getSubTabContextMenu(Wallet wallet, TabPane subTabs, Tab subTab) {
ContextMenu contextMenu = new ContextMenu();
MenuItem rename = new MenuItem("Rename Account");
@@ -1807,7 +1935,7 @@ public class AppController implements Initializable {
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.OK) {
subTabs.getTabs().remove(subTab);
if(subTabs.getTabs().size() == 1) {
setSubTabsVisible(subTabs, false);
setSubTabsVisible(subTabs, areSubTabsVisible());
}
EventManager.get().post(new WalletDeletedEvent(wallet));
}
@@ -1818,6 +1946,48 @@ public class AppController implements Initializable {
return contextMenu;
}
private void configureSwitchServer() {
switchServer.getItems().clear();
Config config = Config.get();
if(config.getServerType() == ServerType.BITCOIN_CORE && config.getRecentCoreServers() != null && config.getRecentCoreServers().size() > 1) {
for(Server server : config.getRecentCoreServers()) {
switchServer.getItems().add(getSwitchServerMenuItem(ServerType.BITCOIN_CORE, server));
}
} else if(config.getServerType() == ServerType.ELECTRUM_SERVER && config.getRecentElectrumServers() != null && config.getRecentElectrumServers().size() > 1) {
for(Server server : config.getRecentElectrumServers()) {
switchServer.getItems().add(getSwitchServerMenuItem(ServerType.ELECTRUM_SERVER, server));
}
}
switchServer.setVisible(!switchServer.getItems().isEmpty());
}
private CheckMenuItem getSwitchServerMenuItem(ServerType serverType, Server server) {
CheckMenuItem checkMenuItem = new CheckMenuItem(server.getDisplayName());
boolean selected = (serverType == ServerType.BITCOIN_CORE ? server.equals(Config.get().getCoreServer()) : server.equals(Config.get().getElectrumServer()));
checkMenuItem.setSelected(selected);
checkMenuItem.setOnAction(event -> {
if(!selected) {
boolean online = onlineProperty().get();
onlineProperty().set(false);
if(serverType == ServerType.BITCOIN_CORE) {
Config.get().setCoreServer(server);
} else if(serverType == ServerType.ELECTRUM_SERVER) {
Config.get().setElectrumServer(server);
}
Platform.runLater(() -> {
onlineProperty().set(online);
configureSwitchServer();
});
} else {
checkMenuItem.setSelected(true);
}
});
return checkMenuItem;
}
public void setServerType(ServerType serverType) {
if(serverType == ServerType.PUBLIC_ELECTRUM_SERVER && !serverToggle.getStyleClass().contains("public-server")) {
serverToggle.getStyleClass().add("public-server");
@@ -1846,7 +2016,7 @@ public class AppController implements Initializable {
private void serverToggleStartAnimation() {
Node thumbArea = serverToggle.lookup(".thumb-area");
if(thumbArea != null) {
Timeline timeline = AnimationUtil.getPulse(thumbArea, Duration.millis(600), 1.0, 0.4, 8);
Timeline timeline = AnimationUtil.getPulse(thumbArea, Duration.millis(600), 1.0, 0.25, 8);
timeline.play();
serverToggle.setUserData(new AnimationUtil.AnimatedNode(thumbArea, timeline));
}
@@ -1882,6 +2052,7 @@ public class AppController implements Initializable {
Label tabLabel = (Label)tab.getGraphic();
if(!tabLabel.getStyleClass().contains("failure")) {
tabLabel.getGraphic().getStyleClass().add("failure");
tabLabel.setTooltip(new Tooltip("Error loading transaction history from server"));
}
}
@@ -1914,6 +2085,7 @@ public class AppController implements Initializable {
private void tabLabelRemoveFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
tabLabel.getGraphic().getStyleClass().remove("failure");
tabLabel.setTooltip(null);
}
private void setTorIcon() {
@@ -1987,9 +2159,10 @@ public class AppController implements Initializable {
saveTransaction.setDisable(true);
lockWallet.setDisable(walletTabData.getWalletForm().lockedProperty().get());
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid() || walletTabData.getWalletForm().isLocked());
refreshWallet.setText(walletTabData.getWallet() == null || walletTabData.getWalletForm().getMasterWallet().getChildWallets().stream().allMatch(Wallet::isNested) ? "Refresh Wallet" : "Refresh Wallet Account");
showLoadingLog.setDisable(false);
showTxHex.setDisable(true);
showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get());
showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
}
}
@@ -2015,10 +2188,22 @@ public class AppController implements Initializable {
if(selectedWalletForm != null) {
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked());
showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get());
showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
}
}
for(Tab walletTab : tabs.getTabs()) {
TabData tabData = (TabData) walletTab.getUserData();
if(tabData instanceof WalletTabData walletTabData) {
if(walletTabData.getWalletForm().getWalletId().equals(event.getWalletId()) && event.getWallet().isMasterWallet()) {
TabPane subTabs = (TabPane)walletTab.getContent();
Tab masterTab = subTabs.getTabs().stream().filter(tab -> ((WalletTabData)tab.getUserData()).getWallet().isMasterWallet()).findFirst().orElse(subTabs.getTabs().get(0));
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(event.getWallet().getLabel() != null ? event.getWallet().getLabel() : event.getWallet().getAutomaticName());
}
}
}
}
@Subscribe
@@ -2045,7 +2230,7 @@ public class AppController implements Initializable {
if(!whirlpoolTransactions.isEmpty()) {
BlockTransaction blockTransaction = whirlpoolTransactions.get(0);
String status;
String walletName = event.getWallet().getMasterName() + " " + event.getWallet().getName().toLowerCase();
String walletName = event.getWallet().getMasterName() + " " + event.getWallet().getName().toLowerCase(Locale.ROOT);
long value = blockTransaction.getTransaction().getOutputs().iterator().next().getValue();
long mempoolValue = whirlpoolTransactions.stream().filter(tx -> tx.getHeight() <= 0).mapToLong(tx -> value).sum();
long blockchainValue = whirlpoolTransactions.stream().filter(tx -> tx.getHeight() > 0).mapToLong(tx -> value).sum();
@@ -2231,11 +2416,12 @@ public class AppController implements Initializable {
@Subscribe
public void connection(ConnectionEvent event) {
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
String status = "Connected to " + Config.get().getServerDisplayName() + " at height " + event.getBlockHeight();
statusUpdated(new StatusEvent(status));
setServerToggleTooltip(event.getBlockHeight());
serverToggleStopAnimation();
setTorIcon();
configureSwitchServer();
}
@Subscribe
@@ -2253,6 +2439,14 @@ public class AppController implements Initializable {
serverToggleStopAnimation();
}
@Subscribe
public void walletOpened(WalletOpenedEvent walletOpenedEvent) {
if(!subTabsVisible && walletOpenedEvent.getWallet().getChildWallets().stream().anyMatch(childWallet -> !childWallet.isNested())) {
subTabsVisible = true;
setSubTabsVisible(true);
}
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
event.getClosedWalletTabData().stream().map(WalletTabData::getWallet).forEach(loadingWallets::remove);
@@ -2426,7 +2620,11 @@ public class AppController implements Initializable {
@Subscribe
public void viewTransaction(ViewTransactionEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex());
if(event.getBlockTransaction() != null) {
addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex());
} else {
addTransactionTab(event.getTransaction(), event.getInitialView(), event.getInitialIndex());
}
}
}
@@ -2443,6 +2641,12 @@ public class AppController implements Initializable {
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
}
@Subscribe
public void unitFormatChanged(UnitFormatChangedEvent event) {
Optional<Toggle> selectedToggle = unitFormat.getToggles().stream().filter(toggle -> event.getUnitFormat().equals(toggle.getUserData())).findFirst();
selectedToggle.ifPresent(toggle -> unitFormat.selectToggle(toggle));
}
@Subscribe
public void openWalletsInNewWindowsStatusChanged(OpenWalletsNewWindowsStatusEvent event) {
openWalletsInNewWindows.setSelected(event.isOpenWalletsInNewWindows());
@@ -2511,6 +2715,8 @@ public class AppController implements Initializable {
lockWallet.setDisable(true);
exportWallet.setDisable(true);
}
lockAllWallets.setDisable(allWalletsLocked(event.getWallet()));
}
@Subscribe
@@ -2519,6 +2725,7 @@ public class AppController implements Initializable {
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
lockWallet.setDisable(false);
exportWallet.setDisable(!event.getWallet().isValid());
lockAllWallets.setDisable(false);
}
}
}
@@ -3,17 +3,23 @@ package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.Auth47;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.control.TextUtils;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
@@ -21,6 +27,7 @@ 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;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -35,7 +42,6 @@ import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
@@ -45,7 +51,7 @@ import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.HyperlinkLabel;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,8 +67,6 @@ import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class AppServices {
@@ -70,9 +74,11 @@ public class AppServices {
private static final int SERVER_PING_PERIOD_SECS = 60;
private static final int PUBLIC_SERVER_RETRY_PERIOD_SECS = 3;
private static final int ENUMERATE_HW_PERIOD_SECS = 30;
private static final int PRIVATE_SERVER_RETRY_PERIOD_SECS = 15;
public static final int ENUMERATE_HW_PERIOD_SECS = 30;
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 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";
@@ -87,9 +93,11 @@ public class AppServices {
private final SorobanServices sorobanServices = new SorobanServices();
private InteractionServices interactionServices;
private static PayNymService payNymService;
private final MainApp application;
private final Application application;
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
@@ -141,7 +149,9 @@ public class AppServices {
torService.reset();
}
torService.start();
if(torService.getState() != Worker.State.RUNNING) {
torService.start();
}
} else {
restartServices();
}
@@ -161,8 +171,9 @@ public class AppServices {
openFiles(event.getFiles(), null);
};
public AppServices(MainApp application) {
private AppServices(Application application, InteractionServices interactionServices) {
this.application = application;
this.interactionServices = interactionServices;
EventManager.get().register(this);
EventManager.get().register(whirlpoolServices);
EventManager.get().register(sorobanServices);
@@ -191,7 +202,7 @@ public class AppServices {
private void restartServices() {
Config config = Config.get();
if(config.hasServerAddress()) {
if(config.hasServer()) {
restartService(connectionService);
}
@@ -199,7 +210,7 @@ public class AppServices {
restartService(ratesService);
}
if(config.isCheckNewVersions() && Network.get() == Network.MAINNET) {
if(config.isCheckNewVersions() && Network.get() == Network.MAINNET && Interface.get() == Interface.DESKTOP) {
restartService(versionCheckService);
}
@@ -247,13 +258,16 @@ public class AppServices {
private ElectrumServer.ConnectionService createConnectionService() {
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService();
//Delay startup on first connection to Bitcoin Core to allow any unencrypted wallets to open first
connectionService.setDelay(Config.get().getServerType() == ServerType.BITCOIN_CORE ? Duration.seconds(CONNECTION_DELAY_SECS) : Duration.ZERO);
connectionService.setPeriod(Duration.seconds(SERVER_PING_PERIOD_SECS));
connectionService.setRestartOnFailure(true);
EventManager.get().register(connectionService);
connectionService.setOnRunning(workerStateEvent -> {
connectionService.setDelay(Duration.ZERO);
if(!ElectrumServer.isConnected()) {
EventManager.get().post(new ConnectionStartEvent(Config.get().getServerAddress()));
EventManager.get().post(new ConnectionStartEvent(Config.get().getServerDisplayName()));
}
});
connectionService.setOnSucceeded(successEvent -> {
@@ -292,9 +306,12 @@ public class AppServices {
"\n\nThis 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) {
crtFile.delete();
Platform.runLater(() -> restartService(connectionService));
return;
if(crtFile.delete()) {
Platform.runLater(() -> restartService(connectionService));
return;
} else {
AppServices.showErrorDialog("Could not delete certificate", "The certificate file at " + crtFile.getAbsolutePath() + " could not be deleted.\n\nPlease delete this file manually.");
}
}
}
}
@@ -314,6 +331,8 @@ public class AppServices {
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
Config.get().changePublicServer();
connectionService.setPeriod(Duration.seconds(PUBLIC_SERVER_RETRY_PERIOD_SECS));
} else {
connectionService.setPeriod(Duration.seconds(PRIVATE_SERVER_RETRY_PERIOD_SECS));
}
log.debug("Connection failed", failEvent.getSource().getException());
@@ -355,7 +374,7 @@ public class AppServices {
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() {
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null);
enumerateService.setPeriod(Duration.seconds(ENUMERATE_HW_PERIOD_SECS));
enumerateService.setPeriod(Duration.seconds(Config.get().getEnumerateHwPeriod()));
enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue();
@@ -479,8 +498,12 @@ public class AppServices {
return proxy;
}
static void initialize(MainApp application) {
INSTANCE = new AppServices(application);
public static void initialize(Application application) {
INSTANCE = new AppServices(application, new DefaultInteractionServices());
}
public static void initialize(Application application, InteractionServices interactionServices) {
INSTANCE = new AppServices(application, interactionServices);
}
public static AppServices get() {
@@ -495,6 +518,10 @@ public class AppServices {
return get().sorobanServices;
}
public static InteractionServices getInteractionServices() {
return get().interactionServices;
}
public static PayNymService getPayNymService() {
if(payNymService == null) {
HostAndPort torProxy = getTorProxy();
@@ -541,7 +568,7 @@ public class AppServices {
private static Image getWindowIcon() {
if(windowIcon == null) {
windowIcon = new Image(MainApp.class.getResourceAsStream("/image/sparrow-icon.png"));
windowIcon = new Image(SparrowWallet.class.getResourceAsStream("/image/sparrow-icon.png"));
}
return windowIcon;
@@ -551,7 +578,7 @@ public class AppServices {
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
}
public MainApp getApplication() {
public Application getApplication() {
return application;
}
@@ -687,6 +714,10 @@ public class AppServices {
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
public static void clearPayjoinURI(Address address) {
payjoinURIs.remove(address);
}
public static void clearTransactionHistoryCache(Wallet wallet) {
ElectrumServer.clearRetrievedScriptHashes(wallet);
@@ -709,38 +740,20 @@ public class AppServices {
return showAlertDialog(title, content == null ? "See log file (Help menu)" : content, Alert.AlertType.ERROR, buttons);
}
public static Optional<ButtonType> showSuccessDialog(String title, String content, ButtonType... buttons) {
Glyph successGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE);
successGlyph.getStyleClass().add("success");
successGlyph.setFontSize(50);
return showAlertDialog(title, content, Alert.AlertType.INFORMATION, successGlyph, buttons);
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
return showAlertDialog(title, content, alertType, null, buttons);
}
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
if(matcher.find()) {
String link = matcher.group(1);
HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content);
hyperlinkLabel.setMaxWidth(Double.MAX_VALUE);
hyperlinkLabel.setMaxHeight(Double.MAX_VALUE);
hyperlinkLabel.getStyleClass().add("content");
Label label = new Label();
hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50));
hyperlinkLabel.setOnAction(event -> {
alert.close();
get().getApplication().getHostServices().showDocument(link);
});
alert.getDialogPane().setContent(hyperlinkLabel);
}
String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum();
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
moveToActiveWindowScreen(alert);
return alert.showAndWait();
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
return getInteractionServices().showAlert(title, content, alertType, graphic, buttons);
}
public static void setStageIcon(Window window) {
@@ -845,6 +858,10 @@ public class AppServices {
Platform.runLater(() -> {
if("bitcoin".equals(uri.getScheme())) {
openBitcoinUri(uri);
} else if(("auth47").equals(uri.getScheme())) {
openAuth47Uri(uri);
} else if(("lightning").equals(uri.getScheme())) {
openLnurlAuthUri(uri);
}
});
}
@@ -867,11 +884,13 @@ public class AppServices {
private static void openBitcoinUri(URI uri) {
try {
BitcoinURI bitcoinURI = new BitcoinURI(uri.toString());
Wallet wallet = selectWallet(null, "pay from");
List<PolicyType> policyTypes = Arrays.asList(PolicyType.values());
List<ScriptType> scriptTypes = Arrays.asList(ScriptType.ADDRESSABLE_TYPES);
Wallet wallet = selectWallet(policyTypes, scriptTypes, true, false, "pay from", false);
if(wallet != null) {
final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet())));
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
}
} catch(Exception e) {
@@ -879,12 +898,97 @@ public class AppServices {
}
}
private static Wallet selectWallet(ScriptType scriptType, String actionDescription) {
private static void openAuth47Uri(URI uri) {
try {
Auth47 auth47 = new Auth47(uri);
List<ScriptType> scriptTypes = PaymentCode.SEGWIT_SCRIPT_TYPES;
Wallet wallet = selectWallet(List.of(PolicyType.SINGLE), scriptTypes, false, true, "login to " + auth47.getCallback().getHost(), true);
if(wallet != null) {
try {
auth47.sendResponse(wallet);
EventManager.get().post(new StatusEvent("Successfully authenticated to " + auth47.getCallback().getHost()));
} catch(Exception e) {
log.error("Error authenticating auth47 URI", e);
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
}
}
} catch(Exception e) {
log.error("Not a valid auth47 URI", e);
showErrorDialog("Not a valid auth47 URI", e.getMessage());
}
}
private static void openLnurlAuthUri(URI uri) {
try {
LnurlAuth lnurlAuth = new LnurlAuth(uri);
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
Wallet wallet = selectWallet(List.of(PolicyType.SINGLE), scriptTypes, true, true, lnurlAuth.getLoginMessage(), true);
if(wallet != null) {
if(wallet.isEncrypted()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
lnurlAuth.sendResponse(copy);
EventManager.get().post(new StatusEvent("Successfully authenticated to " + lnurlAuth.getDomain()));
} catch(Exception e) {
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> openLnurlAuthUri(uri));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
try {
lnurlAuth.sendResponse(wallet);
EventManager.get().post(new StatusEvent("Successfully authenticated to " + lnurlAuth.getDomain()));
} catch(LnurlAuth.LnurlAuthException e) {
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
} catch(Exception e) {
log.error("Failed to authenticate using LNURL-auth", e);
showErrorDialog("Error authenticating", "Failed to authenticate.\n\n" + e.getMessage());
}
}
}
} catch(Exception e) {
log.error("Not a valid LNURL-auth URI", e);
showErrorDialog("Not a valid LNURL-auth URI", e.getMessage());
}
}
private static Wallet selectWallet(List<PolicyType> policyTypes, List<ScriptType> scriptTypes, boolean taprootAllowed, boolean privateKeysRequired, String actionDescription, boolean alwaysAsk) {
Wallet wallet = null;
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> scriptType == null || w.getScriptType() == scriptType).collect(Collectors.toList());
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> w.isValid() && policyTypes.contains(w.getPolicyType()) && scriptTypes.contains(w.getScriptType())
&& (!privateKeysRequired || w.getKeystores().stream().allMatch(Keystore::hasPrivateKey))).collect(Collectors.toList());
if(wallets.isEmpty()) {
showErrorDialog("No wallet available", "Open a" + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1) {
boolean taprootOpen = get().getOpenWallets().keySet().stream().anyMatch(w -> w.getScriptType() == ScriptType.P2TR);
showErrorDialog("No wallet available", "Open a" + (taprootOpen && !taprootAllowed ? " non-Taproot" : "") + (privateKeysRequired ? " software" : "") + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1 && !alwaysAsk) {
wallet = wallets.iterator().next();
} else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
@@ -913,7 +1017,7 @@ public class AppServices {
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
}
@@ -1000,7 +1104,7 @@ public class AppServices {
Wallet wallet = walletTabData.getWallet();
Storage storage = walletTabData.getStorage();
if(!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB)) {
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB))) {
usbWallet = true;
if(deviceEnumerateService == null) {
@@ -1027,12 +1131,18 @@ public class AppServices {
@Subscribe
public void requestConnect(RequestConnectEvent event) {
onlineProperty.set(true);
if(Config.get().hasServer()) {
onlineProperty.set(true);
}
}
@Subscribe
public void requestDisconnect(RequestDisconnectEvent event) {
onlineProperty.set(false);
//Ensure services don't try to reconnect
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
}
@Subscribe
@@ -1042,11 +1152,27 @@ public class AppServices {
@Subscribe
public void walletOpening(WalletOpeningEvent event) {
restartBwt(event.getWallet());
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
Platform.runLater(() -> restartBwt(event.getWallet()));
}
}
@Subscribe
public void childWalletsAdded(ChildWalletsAddedEvent event) {
if(event.getChildWallets().stream().anyMatch(Wallet::isNested)) {
restartBwt(event.getWallet());
}
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && event.getNestedHistoryChangedNodes().stream().anyMatch(node -> node.getTransactionOutputs().isEmpty())) {
Platform.runLater(() -> restartBwt(event.getWallet()));
}
}
private void restartBwt(Wallet wallet) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && isConnected() && wallet.isValid()) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && connectionService != null && connectionService.isConnectionRunning() && wallet.isValid()) {
connectionService.cancel();
}
}
@@ -1063,7 +1189,7 @@ public class AppServices {
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && isConnected()) {
onlineProperty.set(false);
log.warn("Failed to fetch wallet history from " + Config.get().getServerAddress() + ", reconnecting to another server...");
log.warn("Failed to fetch wallet history from " + Config.get().getServerDisplayName() + ", reconnecting to another server...");
Config.get().changePublicServer();
onlineProperty.set(true);
}
@@ -4,6 +4,9 @@ import com.beust.jcommander.Parameter;
import com.sparrowwallet.drongo.Network;
import org.slf4j.event.Level;
import java.util.ArrayList;
import java.util.List;
public class Args {
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
public String dir;
@@ -14,6 +17,31 @@ public class Args {
@Parameter(names = { "--level", "-l" }, description = "Set log level")
public Level level;
@Parameter(names = { "--terminal", "-t" }, description = "Terminal mode", arity = 0)
public boolean terminal;
@Parameter(names = { "--version", "-v" }, description = "Show version", arity = 0)
public boolean version;
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
public boolean help;
public List<String> toParams() {
List<String> params = new ArrayList<>();
if(dir != null) {
params.add("-d");
params.add(dir);
}
if(network != null) {
params.add("-n");
params.add(network.toString());
}
if(level != null) {
params.add("-l");
params.add(level.toString());
}
return params;
}
}
@@ -0,0 +1,65 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.control.KeystorePassphraseDialog;
import com.sparrowwallet.sparrow.control.TextUtils;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Label;
import javafx.scene.text.Font;
import org.controlsfx.control.HyperlinkLabel;
import java.util.Arrays;
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;
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);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
if(graphic != null) {
alert.setGraphic(graphic);
}
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
if(matcher.find()) {
String link = matcher.group(1);
HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content);
hyperlinkLabel.setMaxWidth(Double.MAX_VALUE);
hyperlinkLabel.setMaxHeight(Double.MAX_VALUE);
hyperlinkLabel.getStyleClass().add("content");
Label label = new Label();
hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50));
hyperlinkLabel.setOnAction(event -> {
alert.close();
AppServices.get().getApplication().getHostServices().showDocument(link);
});
alert.getDialogPane().setContent(hyperlinkLabel);
}
String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3 || org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
double numLines = Arrays.stream(lines).mapToDouble(line -> Math.ceil(TextUtils.computeTextWidth(Font.getDefault(), line, 0) / 300)).sum();
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@Override
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
return passphraseDialog.showAndWait();
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.wallet.Keystore;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import java.util.Optional;
public interface InteractionServices {
Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons);
Optional<String> requestPassphrase(String walletName, Keystore keystore);
}
@@ -0,0 +1,34 @@
package com.sparrowwallet.sparrow;
public enum Interface {
DESKTOP, TERMINAL, SERVER;
private static Interface currentInterface;
public static Interface get() {
if(currentInterface == null) {
boolean headless = java.awt.GraphicsEnvironment.isHeadless();
boolean monocle = "Monocle".equalsIgnoreCase(System.getProperty("glass.platform"));
if(headless || monocle) {
currentInterface = TERMINAL;
if(headless && !monocle) {
throw new UnsupportedOperationException("Headless environment detected but Monocle platform not found");
}
} else {
currentInterface = DESKTOP;
}
}
return currentInterface;
}
public static void set(Interface interf) {
if(currentInterface != null && interf != currentInterface) {
throw new IllegalStateException("Interface already set to " + currentInterface);
}
currentInterface = interf;
}
}
@@ -1,7 +1,5 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -12,40 +10,27 @@ import com.sparrowwallet.sparrow.net.PublicElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.instance.InstanceException;
import com.sparrowwallet.sparrow.instance.InstanceList;
import javafx.application.Application;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.GlyphFontRegistry;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import java.io.File;
import java.util.*;
import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.6.1";
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";
public class SparrowDesktop extends Application {
private Stage mainStage;
private static SparrowInstance sparrowInstance;
@Override
public void init() throws Exception {
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
if(e instanceof IndexOutOfBoundsException && Arrays.stream(e.getStackTrace()).anyMatch(element -> element.getClassName().equals("javafx.scene.chart.BarChart"))) {
LoggerFactory.getLogger(MainApp.class).debug("Exception in thread \"" + t.getName() + "\"", e);;
LoggerFactory.getLogger(SparrowWallet.class).debug("Exception in thread \"" + t.getName() + "\"", e);;
} else {
LoggerFactory.getLogger(MainApp.class).error("Exception in thread \"" + t.getName() + "\"", e);
LoggerFactory.getLogger(SparrowWallet.class).error("Exception in thread \"" + t.getName() + "\"", e);
}
});
super.init();
@@ -77,7 +62,7 @@ public class MainApp extends Application {
} else if(Network.get() == Network.MAINNET) {
Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
Config.get().setPublicElectrumServer(servers.get(new Random().nextInt(servers.size())).getUrl());
Config.get().setPublicElectrumServer(servers.get(new Random().nextInt(servers.size())).getServer());
}
}
}
@@ -129,102 +114,9 @@ public class MainApp extends Application {
Config.get().setAppWidth(mainStage.getWidth());
Config.get().setAppHeight(mainStage.getHeight());
mainStage.close();
if(sparrowInstance != null) {
sparrowInstance.freeLock();
}
}
public static void main(String[] argv) {
Args args = new Args();
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase()).acceptUnknownOptions(true).build();
jCommander.parse(argv);
if(args.help) {
jCommander.usage();
System.exit(0);
}
if(args.level != null) {
Drongo.setRootLogLevel(args.level);
}
if(args.dir != null) {
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
}
if(args.network != null) {
Network.set(args.network);
} else {
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
if(envNetwork != null) {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase()));
} catch(Exception e) {
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
if(testnetFlag.exists()) {
Network.set(Network.TESTNET);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
}
if(Network.get() != Network.MAINNET) {
getLogger().info("Using " + Network.get() + " configuration");
}
List<String> fileUriArguments = jCommander.getUnknownOptions();
try {
sparrowInstance = new SparrowInstance(fileUriArguments);
sparrowInstance.acquireLock(); //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);
}
if(!fileUriArguments.isEmpty()) {
AppServices.parseFileUriArguments(fileUriArguments);
}
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
}
private static Logger getLogger() {
return LoggerFactory.getLogger(MainApp.class);
}
private static class SparrowInstance extends InstanceList {
private final List<String> fileUriArguments;
public SparrowInstance(List<String> fileUriArguments) {
super(MainApp.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty());
this.fileUriArguments = fileUriArguments;
}
@Override
protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) {
AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null);
}
}
@Override
protected List<String> sendMessageList() {
return fileUriArguments;
}
@Override
protected void beforeExit() {
getLogger().info("Opening files/URIs in already running instance, exiting...");
SparrowWallet.Instance instance = SparrowWallet.getSparrowInstance();
if(instance != null) {
instance.freeLock();
}
}
}
@@ -0,0 +1,155 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.instance.InstanceException;
import com.sparrowwallet.sparrow.instance.InstanceList;
import com.sparrowwallet.sparrow.terminal.SparrowTerminal;
import com.sun.javafx.application.PlatformImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import java.io.File;
import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.7.0";
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";
private static Instance instance;
public static void main(String[] argv) {
Args args = new Args();
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase(Locale.ROOT)).acceptUnknownOptions(true).build();
jCommander.parse(argv);
if(args.help) {
jCommander.usage();
System.exit(0);
}
if(args.version) {
System.out.println("Sparrow Wallet " + APP_VERSION);
System.exit(0);
}
if(args.level != null) {
Drongo.setRootLogLevel(args.level);
}
if(args.dir != null) {
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
}
if(args.network != null) {
Network.set(args.network);
} else {
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
if(envNetwork != null) {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase(Locale.ROOT)));
} catch(Exception e) {
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
if(testnetFlag.exists()) {
Network.set(Network.TESTNET);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
}
if(Network.get() != Network.MAINNET) {
getLogger().info("Using " + Network.get() + " configuration");
}
List<String> fileUriArguments = jCommander.getUnknownOptions();
try {
instance = new Instance(fileUriArguments);
instance.acquireLock(); //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);
}
if(!fileUriArguments.isEmpty()) {
AppServices.parseFileUriArguments(fileUriArguments);
}
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
if(args.terminal) {
Interface.set(Interface.TERMINAL);
}
try {
if(Interface.get() == Interface.TERMINAL) {
PlatformImpl.setTaskbarApplication(false);
Drongo.removeRootLogAppender("STDOUT");
com.sun.javafx.application.LauncherImpl.launchApplication(SparrowTerminal.class, SparrowWalletPreloader.class, argv);
} else {
com.sun.javafx.application.LauncherImpl.launchApplication(SparrowDesktop.class, SparrowWalletPreloader.class, argv);
}
} catch(UnsupportedOperationException e) {
Drongo.removeRootLogAppender("STDOUT");
getLogger().error("Unable to launch application", e);
System.out.println("No display detected. Use Sparrow Server on a headless (no display) system.");
try {
if(instance != null) {
instance.freeLock();
}
} catch(InstanceException instanceException) {
getLogger().error("Unable to free instance lock", e);
}
}
}
public static Instance getSparrowInstance() {
return instance;
}
private static Logger getLogger() {
return LoggerFactory.getLogger(SparrowWallet.class);
}
public static class Instance extends InstanceList {
private final List<String> fileUriArguments;
public Instance(List<String> fileUriArguments) {
super(SparrowWallet.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty());
this.fileUriArguments = fileUriArguments;
}
@Override
protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) {
AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null);
}
}
@Override
protected List<String> sendMessageList() {
return fileUriArguments;
}
@Override
protected void beforeExit() {
getLogger().info("Opening files/URIs in already running instance, exiting...");
}
}
}
@@ -3,7 +3,7 @@ package com.sparrowwallet.sparrow;
import javafx.application.Preloader;
import javafx.stage.Stage;
public class MainAppPreloader extends Preloader {
public class SparrowWalletPreloader extends Preloader {
@Override
public void start(Stage stage) {
com.sun.glass.ui.Application.GetApplication().setName("Sparrow");
@@ -0,0 +1,86 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.protocol.Transaction;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
public enum UnitFormat {
DOT {
private final DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(getLocale()));
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
public DecimalFormat getCurrencyFormat() {
return currencyFormat;
}
public Locale getLocale() {
return Locale.ENGLISH;
}
},
COMMA {
private final DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(getLocale()));
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
public DecimalFormat getCurrencyFormat() {
return currencyFormat;
}
public Locale getLocale() {
return Locale.GERMAN;
}
};
public abstract Locale getLocale();
public abstract DecimalFormat getBtcFormat();
public abstract DecimalFormat getTableBtcFormat();
public abstract DecimalFormat getCurrencyFormat();
public String formatBtcValue(Long amount) {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String formatSatsValue(Long amount) {
return String.format(getLocale(), "%,d", amount);
}
public String formatCurrencyValue(double amount) {
return getCurrencyFormat().format(amount);
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
return DecimalFormatSymbols.getInstance(getLocale());
}
public String getGroupingSeparator() {
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
}
public String getDecimalSeparator() {
return Character.toString(getDecimalFormatSymbols().getDecimalSeparator());
}
}
@@ -1,17 +1,23 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.Status;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import java.util.Collections;
public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
public AddressCell() {
super();
@@ -37,10 +43,12 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate()));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
setTooltip(tooltip);
if(addressStatus.isDuplicate()) {
if(addressStatus.isDustAttack()) {
setGraphic(getDustAttackHyperlink(utxoEntry));
} else if(addressStatus.isDuplicate()) {
setGraphic(getDuplicateGlyph());
} else {
setGraphic(null);
@@ -49,9 +57,9 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
}
}
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate) {
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate, boolean dustAttack) {
return (utxoEntry.getNode().getWallet().isNested() ? utxoEntry.getNode().getWallet().getDisplayName() + " " : "" ) +
utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : "");
utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : (dustAttack ? " (Possible dust attack)" : ""));
}
public static Glyph getDuplicateGlyph() {
@@ -60,4 +68,22 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
duplicateGlyph.setFontSize(12);
return duplicateGlyph;
}
public static Hyperlink getDustAttackHyperlink(UtxoEntry utxoEntry) {
Glyph dustAttackGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
dustAttackGlyph.getStyleClass().add("dust-attack-warning");
dustAttackGlyph.setFontSize(12);
Hyperlink hyperlink = new Hyperlink(utxoEntry.getHashIndex().getStatus() == Status.FROZEN ? "" : "Freeze?", dustAttackGlyph);
hyperlink.getStyleClass().add("freeze-dust-utxo");
hyperlink.setOnAction(event -> {
if(utxoEntry.getHashIndex().getStatus() != Status.FROZEN) {
hyperlink.setText("");
utxoEntry.getHashIndex().setStatus(Status.FROZEN);
EventManager.get().post(new WalletUtxoStatusChangedEvent(utxoEntry.getWallet(), Collections.singletonList(utxoEntry.getHashIndex())));
}
});
return hyperlink;
}
}
@@ -5,6 +5,8 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ReceiveActionEvent;
import com.sparrowwallet.sparrow.event.ReceiveToEvent;
import com.sparrowwallet.sparrow.event.ShowTransactionsCountEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
import javafx.application.Platform;
@@ -13,14 +15,12 @@ import javafx.collections.ListChangeListener;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.*;
public class AddressTreeTable extends CoinTreeTable {
public void initialize(NodeEntry rootEntry) {
getStyleClass().add("address-treetable");
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
String address = rootEntry.getAddress().toString();
updateAll(rootEntry);
@@ -46,6 +46,15 @@ public class AddressTreeTable extends CoinTreeTable {
labelCol.setSortable(false);
getColumns().add(labelCol);
TreeTableColumn<Entry, Number> countCol = new TreeTableColumn<>("Transactions");
countCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getChildren().size());
});
countCol.setCellFactory(p -> new NumberCell());
countCol.setSortable(false);
countCol.setVisible(Config.get().isShowAddressTransactionCount());
getColumns().add(countCol);
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
amountCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
@@ -54,6 +63,19 @@ public class AddressTreeTable extends CoinTreeTable {
amountCol.setSortable(false);
getColumns().add(amountCol);
ContextMenu contextMenu = new ContextMenu();
CheckMenuItem showCountItem = new CheckMenuItem("Show Transaction Count");
contextMenu.setOnShowing(event -> {
showCountItem.setSelected(Config.get().isShowAddressTransactionCount());
});
showCountItem.setOnAction(event -> {
boolean show = !Config.get().isShowAddressTransactionCount();
Config.get().setShowAddressTransactionCount(show);
EventManager.get().post(new ShowTransactionsCountEvent(show));
});
contextMenu.getItems().add(showCountItem);
getColumns().forEach(col -> col.setContextMenu(contextMenu));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
@@ -92,7 +114,7 @@ public class AddressTreeTable extends CoinTreeTable {
}
public void updateAll(NodeEntry rootEntry) {
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
setRoot(rootItem);
@@ -106,17 +128,38 @@ public class AddressTreeTable extends CoinTreeTable {
}
public void updateHistory(List<WalletNode> updatedNodes) {
//We only ever add child nodes - never remove in order to keep a full sequence
//We only ever add child nodes - never remove in order to keep a full sequence (unless hide empty used addresses is set)
NodeEntry rootEntry = (NodeEntry)getRoot().getValue();
Map<WalletNode, NodeEntry> childNodes = new HashMap<>();
for(Entry childEntry : rootEntry.getChildren()) {
NodeEntry nodeEntry = (NodeEntry)childEntry;
childNodes.put(nodeEntry.getNode(), nodeEntry);
}
for(WalletNode updatedNode : updatedNodes) {
Optional<Entry> optEntry = rootEntry.getChildren().stream().filter(childEntry -> ((NodeEntry)childEntry).getNode().equals(updatedNode)).findFirst();
if(optEntry.isPresent()) {
NodeEntry existingEntry = (NodeEntry)optEntry.get();
NodeEntry existingEntry = childNodes.get(updatedNode);
if(existingEntry != null) {
existingEntry.refreshChildren();
if(Config.get().isHideEmptyUsedAddresses() && existingEntry.getValue() == 0L) {
rootEntry.getChildren().remove(existingEntry);
}
} else {
NodeEntry nodeEntry = new NodeEntry(rootEntry.getWallet(), updatedNode);
rootEntry.getChildren().add(nodeEntry);
if(Config.get().isHideEmptyUsedAddresses()) {
int index = 0;
for( ; index < rootEntry.getChildren().size(); index++) {
existingEntry = (NodeEntry)rootEntry.getChildren().get(index);
if(nodeEntry.compareTo(existingEntry) < 0) {
break;
}
}
rootEntry.getChildren().add(index, nodeEntry);
} else {
rootEntry.getChildren().add(nodeEntry);
}
}
}
@@ -127,4 +170,8 @@ public class AddressTreeTable extends CoinTreeTable {
Entry rootEntry = getRoot().getValue();
rootEntry.updateLabel(entry);
}
public void showTransactionsCount(boolean show) {
getColumns().stream().filter(col -> col.getText().equals("Transactions")).forEach(col -> col.setVisible(show));
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.collect.Lists;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
@@ -35,8 +36,7 @@ public class BalanceChart extends LineChart<Number, Number> {
getData().add(balanceSeries);
update(walletTransactionsEntry);
BitcoinUnit unit = Config.get().getBitcoinUnit();
setBitcoinUnit(walletTransactionsEntry.getWallet(), unit);
setUnitFormat(walletTransactionsEntry.getWallet(), Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void update(WalletTransactionsEntry walletTransactionsEntry) {
@@ -116,12 +116,16 @@ public class BalanceChart extends LineChart<Number, Number> {
}
}
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
public void setUnitFormat(Wallet wallet, UnitFormat format, BitcoinUnit unit) {
if(format == null) {
format = UnitFormat.DOT;
}
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = wallet.getAutoUnit();
}
NumberAxis yaxis = (NumberAxis)getYAxis();
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, unit));
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
}
}
@@ -1,28 +1,31 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import javafx.scene.chart.NumberAxis;
import javafx.util.StringConverter;
import java.text.ParseException;
final class CoinAxisFormatter extends StringConverter<Number> {
private final UnitFormat unitFormat;
private final BitcoinUnit bitcoinUnit;
public CoinAxisFormatter(NumberAxis axis, BitcoinUnit unit) {
public CoinAxisFormatter(NumberAxis axis, UnitFormat format, BitcoinUnit unit) {
this.unitFormat = format;
this.bitcoinUnit = unit;
}
@Override
public String toString(Number object) {
Double value = bitcoinUnit.getValue(object.longValue());
return CoinTextFormatter.COIN_FORMAT.format(value);
return new CoinTextFormatter(unitFormat).getCoinFormat().format(value);
}
@Override
public Number fromString(String string) {
try {
Number number = CoinTextFormatter.COIN_FORMAT.parse(string);
Number number = new CoinTextFormatter(unitFormat).getCoinFormat().parse(string);
return bitcoinUnit.getSatsValue(number.doubleValue());
} catch (ParseException e) {
throw new RuntimeException(e);
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
@@ -14,12 +15,8 @@ import javafx.util.Duration;
import org.controlsfx.tools.Platform;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
class CoinCell extends TreeTableCell<Entry, Number> {
public static final DecimalFormat TABLE_BTC_FORMAT = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final Tooltip tooltip;
public CoinCell() {
@@ -45,10 +42,11 @@ class CoinCell extends TreeTableCell<Entry, Number> {
EntryCell.applyRowStyles(this, entry);
CoinTreeTable coinTreeTable = (CoinTreeTable)getTreeTableView();
UnitFormat format = coinTreeTable.getUnitFormat();
BitcoinUnit unit = coinTreeTable.getBitcoinUnit();
String satsValue = String.format(Locale.ENGLISH, "%,d", amount.longValue());
DecimalFormat decimalFormat = (amount.longValue() == 0L ? CoinLabel.getBTCFormat() : TABLE_BTC_FORMAT);
String satsValue = format.formatSatsValue(amount.longValue());
DecimalFormat decimalFormat = (amount.longValue() == 0L ? format.getBtcFormat() : format.getTableBtcFormat());
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
@@ -61,8 +59,7 @@ class CoinCell extends TreeTableCell<Entry, Number> {
setTooltip(tooltip);
String tooltipValue = tooltip.getText();
if(entry instanceof TransactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleLongProperty;
@@ -12,13 +12,7 @@ import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
public class CoinLabel extends Label {
public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
private final CoinContextMenu contextMenu;
@@ -58,8 +52,9 @@ public class CoinLabel extends Label {
setTooltip(tooltip);
setContextMenu(contextMenu);
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
String btcValue = BTC_FORMAT.format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
String satsValue = format.formatSatsValue(value) + " sats";
String btcValue = format.formatBtcValue(value) + " BTC";
BitcoinUnit unit = bitcoinUnit;
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
@@ -89,16 +84,12 @@ public class CoinLabel extends Label {
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(BTC_FORMAT.format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(getValue()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
}
}
public static DecimalFormat getBTCFormat() {
BTC_FORMAT.setMaximumFractionDigits(8);
return BTC_FORMAT;
}
}
@@ -1,24 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.UnitFormat;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextInputControl;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.ParseException;
import java.util.Locale;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
private static final Pattern COIN_VALIDATION = Pattern.compile("[\\d,]*(\\.\\d{0,8})?");
public static final DecimalFormat COIN_FORMAT = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
}
public CoinTextFormatter() {
super(new CoinFilter());
public UnitFormat getUnitFormat() {
return ((CoinFilter)getFilter()).unitFormat;
}
public DecimalFormat getCoinFormat() {
return ((CoinFilter)getFilter()).coinFormat;
}
private static class CoinFilter implements UnaryOperator<Change> {
private final UnitFormat unitFormat;
private final DecimalFormat coinFormat;
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(unitFormat.getLocale()));
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@Override
public Change apply(Change change) {
String oldText = change.getControlText();
@@ -30,17 +45,17 @@ public class CoinTextFormatter extends TextFormatter<String> {
String noFractionCommaText = newText;
int commasRemoved = 0;
int dotIndex = newText.indexOf(".");
int dotIndex = newText.indexOf(unitFormat.getDecimalSeparator());
if(dotIndex > -1) {
noFractionCommaText = newText.substring(0, dotIndex) + newText.substring(dotIndex).replaceAll(",", "");
noFractionCommaText = newText.substring(0, dotIndex) + newText.substring(dotIndex).replaceAll(Pattern.quote(unitFormat.getGroupingSeparator()), "");
commasRemoved = newText.length() - noFractionCommaText.length();
}
if(!COIN_VALIDATION.matcher(noFractionCommaText).matches()) {
if(!coinValidation.matcher(noFractionCommaText).matches()) {
return null;
}
if(",".equals(change.getText())) {
if(unitFormat.getGroupingSeparator().equals(change.getText())) {
return null;
}
@@ -48,20 +63,20 @@ public class CoinTextFormatter extends TextFormatter<String> {
return change;
}
if(change.isDeleted() && ",".equals(deleted) && change.getRangeStart() > 0) {
if(change.isDeleted() && unitFormat.getGroupingSeparator().equals(deleted) && change.getRangeStart() > 0) {
noFractionCommaText = noFractionCommaText.substring(0, change.getRangeStart() - 1) + noFractionCommaText.substring(change.getRangeEnd() - 1);
}
try {
Number value = COIN_FORMAT.parse(noFractionCommaText);
String correct = COIN_FORMAT.format(value.doubleValue());
Number value = coinFormat.parse(noFractionCommaText);
String correct = coinFormat.format(value.doubleValue());
String compare = newText;
if(compare.contains(".") && compare.endsWith("0")) {
if(compare.contains(unitFormat.getDecimalSeparator()) && compare.endsWith("0")) {
compare = compare.replaceAll("0*$", "");
}
if(compare.endsWith(".")) {
if(compare.endsWith(unitFormat.getDecimalSeparator())) {
compare = compare.substring(0, compare.length() - 1);
}
@@ -79,11 +94,11 @@ public class CoinTextFormatter extends TextFormatter<String> {
if(correct.length() != newText.length()) {
String postCorrect = correct.substring(Math.min(change.getCaretPosition(), correct.length()));
int commasAfter = postCorrect.length() - postCorrect.replace(",", "").length();
int caretShift = change.isDeleted() && ".".equals(deleted) ? commasAfter : 0;
int commasAfter = postCorrect.length() - postCorrect.replace(unitFormat.getGroupingSeparator(), "").length();
int caretShift = change.isDeleted() && unitFormat.getDecimalSeparator().equals(deleted) ? commasAfter : 0;
int caret = change.getCaretPosition() + (correct.length() - newText.length() - caretShift) + commasRemoved;
if(caret >= 0) {
if(caret >= 0 && caret <= change.getControlNewText().length()) {
change.setCaretPosition(caret);
change.setAnchor(caret);
}
@@ -2,10 +2,11 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
@@ -26,25 +27,36 @@ import java.util.Optional;
public class CoinTreeTable extends TreeTableView<Entry> {
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
public BitcoinUnit getBitcoinUnit() {
return bitcoinUnit;
}
public void setBitcoinUnit(BitcoinUnit bitcoinUnit) {
this.bitcoinUnit = bitcoinUnit;
public UnitFormat getUnitFormat() {
return unitFormat;
}
public void setBitcoinUnit(Wallet wallet) {
setBitcoinUnit(wallet, Config.get().getBitcoinUnit());
public void setUnitFormat(Wallet wallet) {
setUnitFormat(wallet, Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
public void setUnitFormat(Wallet wallet, UnitFormat format) {
setUnitFormat(wallet, format, Config.get().getBitcoinUnit());
}
public void setUnitFormat(Wallet wallet, UnitFormat format, BitcoinUnit unit) {
if(format == null) {
format = UnitFormat.DOT;
}
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = wallet.getAutoUnit();
}
boolean changed = (bitcoinUnit != unit);
boolean changed = (unitFormat != format);
changed |= (bitcoinUnit != unit);
this.unitFormat = format;
this.bitcoinUnit = unit;
if(changed && !getChildren().isEmpty()) {
@@ -91,7 +103,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
}
});
if(wallet.getBirthDate() == null) {
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleLongProperty;
@@ -11,8 +11,6 @@ import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.util.Locale;
public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
@@ -24,7 +22,7 @@ public class CopyableCoinLabel extends CopyableLabel {
public CopyableCoinLabel(String text) {
super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getBitcoinUnit()));
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
tooltip = new Tooltip();
contextMenu = new CoinContextMenu();
}
@@ -42,19 +40,23 @@ public class CopyableCoinLabel extends CopyableLabel {
}
public void refresh() {
refresh(Config.get().getBitcoinUnit());
refresh(Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void refresh(BitcoinUnit bitcoinUnit) {
setValueAsText(getValue(), bitcoinUnit);
public void refresh(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
setValueAsText(getValue(), unitFormat, bitcoinUnit);
}
private void setValueAsText(Long value, BitcoinUnit bitcoinUnit) {
private void setValueAsText(Long value, UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
setTooltip(tooltip);
setContextMenu(contextMenu);
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
String btcValue = CoinLabel.getBTCFormat().format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
if(unitFormat == null) {
unitFormat = UnitFormat.DOT;
}
String satsValue = unitFormat.formatSatsValue(value) + " sats";
String btcValue = unitFormat.formatBtcValue(value) + " BTC";
BitcoinUnit unit = bitcoinUnit;
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
@@ -84,7 +86,8 @@ public class CopyableCoinLabel extends CopyableLabel {
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(CoinLabel.getBTCFormat().format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(getValue()));
Clipboard.getSystemClipboard().setContent(content);
});
@@ -0,0 +1,26 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.control.Button;
public class DescriptorQRDisplayDialog extends QRDisplayDialog {
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur) {
super(ur);
DialogPane dialogPane = getDialogPane();
final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().add(pdfButtonType);
Button pdfButton = (Button)dialogPane.lookupButton(pdfButtonType);
pdfButton.setGraphicTextGap(5);
pdfButton.setGraphic(getGlyph(FontAwesome5.Glyph.FILE_PDF));
pdfButton.addEventFilter(ActionEvent.ACTION, event -> {
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur);
event.consume();
});
}
}
@@ -3,16 +3,13 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform;
import javafx.geometry.Pos;
@@ -39,6 +36,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
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+)?\\).*");
private static EntryCell lastCell;
public EntryCell() {
super();
setAlignment(Pos.CENTER_LEFT);
@@ -50,6 +49,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
protected void updateItem(Entry entry, boolean empty) {
super.updateItem(entry, empty);
//Return immediately to avoid CPU usage when updating the same invisible cell to determine tableview size (see https://bugs.openjdk.org/browse/JDK-8280442)
if(this == lastCell && !getTableRow().isVisible()) {
return;
}
lastCell = this;
applyRowStyles(this, entry);
if(empty) {
@@ -91,11 +96,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
increaseFeeButton.setOnAction(event -> {
increaseFee(transactionEntry);
increaseFee(transactionEntry, false);
});
actionBox.getChildren().add(increaseFeeButton);
}
@@ -183,7 +189,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
}
private static void increaseFee(TransactionEntry transactionEntry) {
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
@@ -195,6 +201,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
if(utxos.isEmpty()) {
log.error("No UTXOs to replace");
AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction - no replaceable UTXOs were found.");
return;
}
List<TransactionOutput> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
@@ -209,14 +221,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
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()) {
//If there is insufficent change output, include another random UTXO so the fee can be increased
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();
@@ -226,6 +240,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
externalOutputs.removeAll(ourOutputs);
externalOutputs.addAll(consolidationOutputs);
final long rbfChange = changeTotal;
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
@@ -242,20 +257,46 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
}
return new Payment(txOutput.getScript().getToAddresses()[0], label, txOutput.getValue(), false);
if(txOutput.getScript().getToAddress() != null) {
//Disable change creation by enabling max payment when there is only one output and no additional UTXOs included
return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0);
}
return null;
} catch(Exception e) {
log.error("Error creating RBF payment", e);
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toList());
List<byte[]> opReturns = externalOutputs.stream().map(txOutput -> {
List<ScriptChunk> scriptChunks = txOutput.getScript().getChunks();
if(scriptChunks.size() != 2 || scriptChunks.get(0).getOpcode() != ScriptOpCodes.OP_RETURN) {
return null;
}
if(scriptChunks.get(1).getData() != null) {
return scriptChunks.get(1).getData();
}
return null;
}).filter(Objects::nonNull).collect(Collectors.toList());
if(payments.isEmpty()) {
AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction, check log for details");
return;
}
if(cancelTransaction) {
Payment existing = payments.get(0);
Address address = transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress();
Payment payment = new Payment(address, existing.getLabel(), existing.getAmount(), true);
payments.clear();
payments.add(payment);
opReturns.clear();
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true)));
}
private static Double getMaxFeeRate() {
@@ -287,7 +328,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), 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), blockTransaction.getFee(), false)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false)));
}
private static boolean canSignMessage(WalletNode walletNode) {
@@ -348,11 +389,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
private String getTooltip(TransactionEntry transactionEntry) {
String tooltip = transactionEntry.getBlockTransaction().getHash().toString();
if(transactionEntry.getBlockTransaction().getHeight() <= 0) {
if(!AppServices.getMempoolHistogram().isEmpty()) {
Set<MempoolRateSize> rateSizes = AppServices.getMempoolHistogram().get(AppServices.getMempoolHistogram().lastKey());
double vSize = transactionEntry.getBlockTransaction().getTransaction().getVirtualSize();
double feeRate = transactionEntry.getBlockTransaction().getFee() / vSize;
long vSizefromTip = rateSizes.stream().filter(rateSize -> rateSize.getFee() > feeRate).mapToLong(MempoolRateSize::getVSize).sum();
Double feeRate = transactionEntry.getBlockTransaction().getFeeRate();
Long vSizefromTip = transactionEntry.getVSizeFromTip();
if(feeRate != null && vSizefromTip != null) {
long blocksFromTip = (long)Math.ceil((double)vSizefromTip / Transaction.MAX_BLOCK_SIZE);
String amount = vSizefromTip + " vB";
if(vSizefromTip > 1000 * 1000) {
amount = String.format("%.2f", (double)vSizefromTip / (1000 * 1000)) + " MvB";
@@ -360,7 +401,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
amount = String.format("%.2f", (double)vSizefromTip / 1000) + " kvB";
}
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB (" + amount + " from tip)";
tooltip += "\nConfirms in: " + (blocksFromTip > 1 ? blocksFromTip + "+ blocks" : "1 block") + " (" + amount + " from tip)";
}
if(feeRate != null) {
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
}
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled");
@@ -381,6 +426,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
return increaseFeeGlyph;
}
private static Glyph getCancelTransactionRBFGlyph() {
Glyph cancelTxGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BAN);
cancelTxGlyph.setFontSize(12);
return cancelTxGlyph;
}
private static Glyph getIncreaseFeeCPFPGlyph() {
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
cpfpGlyph.setFontSize(12);
@@ -434,17 +485,28 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
hide();
increaseFee(transactionEntry);
increaseFee(transactionEntry, false);
});
getItems().add(increaseFee);
}
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> {
hide();
increaseFee(transactionEntry, true);
});
getItems().add(cancelTx);
}
if(containsWalletOutputs(transactionEntry)) {
MenuItem createCpfp = new MenuItem("Increase Effective Fee (CPFP)");
createCpfp.setGraphic(getIncreaseFeeCPFPGlyph());
@@ -529,6 +591,42 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(signVerifyMessage);
}
if(nodeEntry != null && !nodeEntry.getNode().getUnspentTransactionOutputs().isEmpty()) {
List<BlockTransactionHashIndex> utxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().collect(Collectors.toList());
MenuItem spendUtxos = new MenuItem("Spend UTXOs");
spendUtxos.setGraphic(getSendGlyph());
spendUtxos.setOnAction(AE -> {
hide();
EventManager.get().post(new SendActionEvent(nodeEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(nodeEntry.getWallet(), utxos)));
});
getItems().add(spendUtxos);
List<BlockTransactionHashIndex> unfrozenUtxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().filter(utxo -> utxo.getStatus() != Status.FROZEN).collect(Collectors.toList());
if(!unfrozenUtxos.isEmpty()) {
MenuItem freezeUtxos = new MenuItem("Freeze UTXOs");
freezeUtxos.setGraphic(getFreezeGlyph());
freezeUtxos.setOnAction(AE -> {
hide();
unfrozenUtxos.forEach(utxo -> utxo.setStatus(Status.FROZEN));
EventManager.get().post(new WalletUtxoStatusChangedEvent(nodeEntry.getWallet(), unfrozenUtxos));
});
getItems().add(freezeUtxos);
}
List<BlockTransactionHashIndex> frozenUtxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().filter(utxo -> utxo.getStatus() == Status.FROZEN).collect(Collectors.toList());
if(!frozenUtxos.isEmpty()) {
MenuItem unfreezeUtxos = new MenuItem("Unfreeze UTXOs");
unfreezeUtxos.setGraphic(getUnfreezeGlyph());
unfreezeUtxos.setOnAction(AE -> {
hide();
frozenUtxos.forEach(utxo -> utxo.setStatus(null));
EventManager.get().post(new WalletUtxoStatusChangedEvent(nodeEntry.getWallet(), frozenUtxos));
});
getItems().add(unfreezeUtxos);
}
}
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(AE -> {
hide();
@@ -1,7 +1,9 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.*;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
@@ -10,14 +12,9 @@ import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Currency;
import java.util.Locale;
public class FiatLabel extends CopyableLabel {
private static final DecimalFormat CURRENCY_FORMAT = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final DoubleProperty btcRateProperty = new SimpleDoubleProperty(0.0);
private final ObjectProperty<Currency> currencyProperty = new SimpleObjectProperty<>(null);
@@ -30,9 +27,9 @@ public class FiatLabel extends CopyableLabel {
public FiatLabel(String text) {
super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue));
btcRateProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue()));
currencyProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue()));
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat()));
btcRateProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue(), Config.get().getUnitFormat()));
currencyProperty().addListener((observable, oldValue, newValue) -> setValueAsText(getValue(), Config.get().getUnitFormat()));
tooltip = new Tooltip();
contextMenu = new FiatContextMenu();
}
@@ -83,14 +80,22 @@ public class FiatLabel extends CopyableLabel {
setCurrency(currency);
}
private void setValueAsText(long balance) {
public void refresh() {
refresh(Config.get().getUnitFormat());
}
public void refresh(UnitFormat unitFormat) {
setValueAsText(getValue(), unitFormat);
}
private void setValueAsText(long balance, UnitFormat unitFormat) {
if(getCurrency() != null && getBtcRate() > 0.0) {
BigDecimal satsBalance = BigDecimal.valueOf(balance);
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(getBtcRate()));
String label = getCurrency().getSymbol() + " " + CURRENCY_FORMAT.format(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + getCurrency().getSymbol() + " " + CURRENCY_FORMAT.format(getBtcRate()));
String label = getCurrency().getSymbol() + " " + unitFormat.formatCurrencyValue(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + getCurrency().getSymbol() + " " + unitFormat.formatCurrencyValue(getBtcRate()));
setText(label);
setTooltip(tooltip);
@@ -16,7 +16,7 @@ public class FileKeystoreImportPane extends FileImportPane {
private final KeyDerivation requiredDerivation;
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer, KeyDerivation requiredDerivation) {
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
this.wallet = wallet;
this.importer = importer;
this.requiredDerivation = requiredDerivation;
@@ -34,4 +34,17 @@ public class FileKeystoreImportPane extends FileImportPane {
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;
}
}
@@ -21,6 +21,7 @@ import org.controlsfx.glyphfont.Glyph;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Optional;
public class FileWalletExportPane extends TitledDescriptionPane {
@@ -80,8 +81,8 @@ 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().replace(" ", "");
if(exporter instanceof Sparrow) {
String fileName = wallet.getFullName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
if(exporter.exportsAllWallets()) {
fileName = wallet.getMasterName();
}
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
@@ -21,6 +21,7 @@ public class HelpLabel extends Label {
tooltip.textProperty().bind(helpTextProperty());
tooltip.graphicProperty().bind(helpGraphicProperty());
tooltip.setShowDuration(Duration.seconds(15));
tooltip.setShowDelay(Duration.millis(500));
getStyleClass().add("help-label");
Platform.runLater(() -> setTooltip(tooltip));
@@ -0,0 +1,83 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.NamedArg;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.util.converter.IntegerStringConverter;
public class IntegerSpinner extends Spinner<Integer> {
public IntegerSpinner() {
super();
setupEditor();
}
public IntegerSpinner(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue) {
super(min, max, initialValue);
setupEditor();
}
public IntegerSpinner(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue,
@NamedArg("amountToStepBy") int amountToStepBy) {
super(min, max, initialValue, amountToStepBy);
setupEditor();
}
private void setupEditor() {
getEditor().focusedProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null && !newValue) {
commitValue();
}
});
getEditor().textProperty().addListener((observable, oldValue, newValue) -> {
if(!newValue.matches("\\d*")) {
getEditor().setText(newValue.replaceAll("[^\\d]", ""));
}
});
}
public static class ValueFactory extends SpinnerValueFactory.IntegerSpinnerValueFactory {
public ValueFactory(@NamedArg("min") int min,
@NamedArg("max") int max) {
super(min, max);
setupConverter(min);
}
public ValueFactory(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue) {
super(min, max, initialValue);
setupConverter(initialValue);
}
public ValueFactory(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue,
@NamedArg("amountToStepBy") int amountToStepBy) {
super(min, max, initialValue, amountToStepBy);
setupConverter(initialValue);
}
private void setupConverter(Integer defaultValue) {
setConverter(new IntegerStringConverter() {
@Override
public Integer fromString(String value) {
if(value == null) {
return null;
}
value = value.trim();
if(value.length() < 1) {
return defaultValue;
}
return Integer.valueOf(value);
}
});
}
}
}
@@ -37,6 +37,7 @@ import tornadofx.control.Form;
import java.security.SignatureException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
@@ -127,6 +128,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"));
addressField.getInputs().add(address);
if(walletNode != null) {
@@ -184,7 +186,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(buttons.length > 0) {
dialogPane.getButtonTypes().addAll(buttons);
ButtonType customSignButtonType = Arrays.stream(buttons).filter(buttonType -> buttonType.getText().toLowerCase().contains("sign")).findFirst().orElse(null);
ButtonType customSignButtonType = Arrays.stream(buttons).filter(buttonType -> buttonType.getText().toLowerCase(Locale.ROOT).contains("sign")).findFirst().orElse(null);
if(customSignButtonType != null) {
Button customSignButton = (Button)dialogPane.lookupButton(customSignButtonType);
customSignButton.setDefaultButton(true);
@@ -293,7 +295,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private boolean isValidAddress() {
try {
Address address = getAddress();
return address.getScriptType() != ScriptType.P2TR;
return address.getScriptType() != ScriptType.P2TR && address.getScriptType().isAllowed(PolicyType.SINGLE);
} catch (InvalidAddressException e) {
return false;
}
@@ -370,15 +372,9 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
if(verified) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
AppServices.setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.setTitle("Verification Succeeded");
alert.setHeaderText("Verification Succeeded");
alert.setContentText("The signature verified against the message.");
AppServices.moveToActiveWindowScreen(alert);
alert.showAndWait();
AppServices.showSuccessDialog("Verification Succeeded", "The signature verified against the message.");
} else {
AppServices.showErrorDialog("Verification failed", "The provided signature did not match the message for this address.");
AppServices.showErrorDialog("Verification Failed", "The provided signature did not match the message for this address.");
}
} catch(IllegalArgumentException e) {
AppServices.showErrorDialog("Could not verify message", e.getMessage());
@@ -17,6 +17,8 @@ import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import java.util.Locale;
public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
private static final int ERROR_DISPLAY_MILLIS = 5 * 60 * 1000;
@@ -114,7 +116,7 @@ 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() + mixProgress.getMixStep().getMessage().substring(1);
String status = mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase(Locale.ROOT) + mixProgress.getMixStep().getMessage().substring(1);
tt.setText(status);
setTooltip(tt);
@@ -17,25 +17,13 @@ import java.util.List;
public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
public MnemonicKeystoreDisplayPane(Keystore keystore) {
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase entered" : "No passphrase", "", "image/" + WalletModel.SEED.getType() + ".png");
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() && (keystore.getSeed().getPassphrase() == null || keystore.getSeed().getPassphrase().length() > 0) ? "Passphrase entered" : "No passphrase", "", "image/" + WalletModel.SEED.getType() + ".png");
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
showWordList(keystore.getSeed());
}
private void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
}
}
protected Node getMnemonicWordsEntry(int numWords) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -1,6 +1,8 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
@@ -26,13 +28,16 @@ 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 java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.*;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class MnemonicKeystorePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(MnemonicKeystorePane.class);
protected SplitMenuButton enterMnemonicButton;
protected TilePane wordsPane;
protected Label validLabel;
@@ -73,9 +78,44 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
});
enterMnemonicButton.getItems().add(item);
}
enterMnemonicButton.getItems().add(new SeparatorMenuItem());
MenuItem scanItem = new MenuItem("Scan QR...");
scanItem.setOnAction(event -> {
scanQR();
});
enterMnemonicButton.getItems().add(scanItem);
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
}
protected void scanQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.seed != null) {
showWordList(result.seed);
Platform.runLater(() -> validLabel.requestFocus());
} 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 seed.");
}
}
}
protected void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
}
}
protected void enterMnemonic(int numWords) {
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
@@ -231,7 +271,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
String text = change.getText();
// if text was added, fix the text to fit the requirements
if(!text.isEmpty()) {
String newText = text.replace(" ", "").toLowerCase();
String newText = text.replace(" ", "").toLowerCase(Locale.ROOT);
int carretPos = change.getCaretPosition() - text.length() + newText.length();
change.setText(newText);
// fix caret position based on difference in originally added text and fixed text
@@ -0,0 +1,28 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.TreeTableCell;
import org.controlsfx.tools.Platform;
public class NumberCell extends TreeTableCell<Entry, Number> {
public NumberCell() {
super();
getStyleClass().add("number-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);
} else {
setText(amount.toString());
setGraphic(null);
}
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
@@ -39,7 +40,7 @@ public class PayNymAvatar extends StackPane {
String cacheId = getCacheId(paymentCode, getPrefWidth());
if(paymentCodeCache.containsKey(cacheId)) {
setImage(paymentCodeCache.get(cacheId));
} else {
} else if(AppServices.isConnected()) {
PayNymAvatarService payNymAvatarService = new PayNymAvatarService(paymentCode, getPrefWidth());
payNymAvatarService.setOnRunning(runningEvent -> {
getChildren().clear();
@@ -48,7 +49,7 @@ public class PayNymAvatar extends StackPane {
setImage(payNymAvatarService.getValue());
});
payNymAvatarService.setOnFailed(failedEvent -> {
log.error("Error", failedEvent.getSource().getException());
log.debug("Error loading PayNym avatar", failedEvent.getSource().getException());
});
payNymAvatarService.start();
}
@@ -117,8 +118,12 @@ public class PayNymAvatar extends StackPane {
}
synchronized(lock) {
String url = "https://paynym.is/" + paymentCodeStr + "/avatar";
Proxy proxy = AppServices.getProxy();
String url = PayNymService.getHostUrl(proxy != null) + "/" + paymentCodeStr + "/avatar";
if(log.isDebugEnabled()) {
log.debug("Requesting PayNym avatar from " + url);
}
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false);
@@ -1,5 +1,8 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletLabelChangedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.paynym.PayNym;
import com.sparrowwallet.sparrow.paynym.PayNymController;
@@ -10,16 +13,20 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.Glyph;
import java.util.Optional;
public class PayNymCell extends ListCell<PayNym> {
private final PayNymController payNymController;
private final boolean contact;
public PayNymCell(PayNymController payNymController) {
public PayNymCell(PayNymController payNymController, boolean contact) {
super();
setAlignment(Pos.CENTER_LEFT);
setContentDisplay(ContentDisplay.LEFT);
getStyleClass().add("paynym-cell");
setPrefHeight(50);
this.payNymController = payNymController;
this.contact = contact;
}
@Override
@@ -47,7 +54,7 @@ public class PayNymCell extends ListCell<PayNym> {
labelBox.getChildren().add(label);
pane.setLeft(labelBox);
if(getListView().getUserData() == Boolean.TRUE) {
if(getListView().getUserData() == Boolean.TRUE || (!contact && payNymController != null && payNymController.isFollowing(payNym) == Boolean.FALSE)) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
Button button = new Button("Add Contact");
@@ -57,7 +64,7 @@ public class PayNymCell extends ListCell<PayNym> {
button.setDisable(true);
payNymController.followPayNym(payNym.paymentCode());
});
} else if(payNymController != null) {
} else if(contact && payNymController != null) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
pane.setRight(hBox);
@@ -83,6 +90,12 @@ public class PayNymCell extends ListCell<PayNym> {
setText(null);
setGraphic(pane);
if(contact && payNymController != null && payNymController.isLinked(payNym)) {
setContextMenu(new PayNymCellContextMenu(payNym));
} else {
setContextMenu(null);
}
}
}
@@ -91,4 +104,30 @@ public class PayNymCell extends ListCell<PayNym> {
failGlyph.setFontSize(12);
return failGlyph;
}
private class PayNymCellContextMenu extends ContextMenu {
public PayNymCellContextMenu(PayNym payNym) {
MenuItem rename = new MenuItem("Rename Contact...");
rename.setOnAction(event -> {
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) {
int index = getListView().getItems().indexOf(payNym);
for(Wallet childWallet : payNymController.getMasterWallet().getChildWallets()) {
if(childWallet.isBip47()
&& childWallet.getKeystores().get(0).getExternalPaymentCode().equals(payNym.paymentCode())
&& (childWallet.getLabel() == null || childWallet.getLabel().startsWith(payNym.nymName()))) {
childWallet.setLabel(optLabel.get() + " " + childWallet.getScriptType().getName());
EventManager.get().post(new WalletLabelChangedEvent(childWallet));
}
}
payNymController.updateFollowing();
getListView().getSelectionModel().select(index);
}
});
getItems().add(rename);
}
}
}
@@ -4,6 +4,7 @@ import com.google.common.io.Files;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.BIP38;
import com.sparrowwallet.drongo.crypto.DumpedPrivateKey;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -27,6 +28,7 @@ import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.StringConverter;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
@@ -80,7 +82,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
keyField.setText("Private Key:");
key = new TextArea();
key.setWrapText(true);
key.setPromptText("Wallet Import Format (WIF)");
key.setPromptText("Wallet Import Format (WIF) or BIP38 encrypted key");
key.setPrefRowCount(2);
key.getStyleClass().add("fixed-width");
HBox keyBox = new HBox(5);
@@ -149,6 +151,10 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
});
key.textProperty().addListener((observable, oldValue, newValue) -> {
if(isEncryptedKey()) {
decryptKey();
}
boolean isValidKey = isValidKey();
createButton.setDisable(!isValidKey || !isValidToAddress());
if(isValidKey) {
@@ -199,6 +205,27 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
}
}
private boolean isEncryptedKey() {
return key.getText().length() == 58 && key.getText().startsWith("6P");
}
private void decryptKey() {
PassphraseDialog passphraseDialog = new PassphraseDialog();
Optional<String> optPassphrase = passphraseDialog.showAndWait();
if(optPassphrase.isPresent()) {
try {
DumpedPrivateKey decryptedKey = BIP38.decrypt(optPassphrase.get(), key.getText());
Platform.runLater(() -> key.setText(decryptedKey.toString()));
} catch(Exception e) {
log.error("Failed to decrypt BIP38 key", e);
AppServices.showErrorDialog("Failed to decrypt BIP38 key", e.getMessage());
Platform.runLater(() -> key.setText(""));
}
} else {
Platform.runLater(() -> key.setText(""));
}
}
private DumpedPrivateKey getPrivateKey() {
return DumpedPrivateKey.fromBase58(key.getText());
}
@@ -342,4 +369,35 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
glyph.setFontSize(12);
return glyph;
}
public class PassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase;
public PassphraseDialog() {
this.passphrase = new ViewPasswordField();
final DialogPane dialogPane = getDialogPane();
setTitle("BIP38 Passphrase");
dialogPane.setHeaderText("Enter the BIP38 passphrase for this key:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(200);
AppServices.moveToActiveWindowScreen(this);
Glyph key = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.KEY);
key.setFontSize(50);
dialogPane.setGraphic(key);
final VBox content = new VBox(10);
content.setPrefHeight(50);
content.getChildren().add(passphrase);
dialogPane.setContent(content);
Platform.runLater(passphrase::requestFocus);
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? passphrase.getText() : null);
}
}
}
@@ -25,6 +25,7 @@ public class ProgressTimer extends ProgressIndicator {
}
public void start(EventHandler<ActionEvent> onFinished) {
getStyleClass().remove("warn");
timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(progressProperty(), 0)),
new KeyFrame(Duration.seconds(getSeconds() * 0.8), e -> getStyleClass().add("warn")),
@@ -27,6 +27,7 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
@SuppressWarnings("deprecation")
public class QRDisplayDialog extends Dialog<UR> {
@@ -78,12 +79,7 @@ public class QRDisplayDialog extends Dialog<UR> {
if(encoder.isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
animateQRService = new AnimateQRService();
animateQRService.setPeriod(Duration.millis(ANIMATION_PERIOD_MILLIS));
animateQRService.start();
setOnCloseRequest(event -> {
animateQRService.cancel();
});
createAnimateQRService();
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -124,10 +120,19 @@ public class QRDisplayDialog extends Dialog<UR> {
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
}
private void createAnimateQRService() {
animateQRService = new AnimateQRService();
animateQRService.setPeriod(Duration.millis(ANIMATION_PERIOD_MILLIS));
animateQRService.start();
setOnCloseRequest(event -> {
animateQRService.cancel();
});
}
private void nextPart() {
if(!useLegacyEncoding) {
String fragment = encoder.nextPart();
currentPart = fragment.toUpperCase();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else {
currentPart = legacyParts[legacyPartIndex];
legacyPartIndex++;
@@ -137,7 +142,7 @@ public class QRDisplayDialog extends Dialog<UR> {
}
}
private Image getQrCode(String fragment) {
protected Image getQrCode(String fragment) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
@@ -169,6 +174,8 @@ public class QRDisplayDialog extends Dialog<UR> {
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
@@ -185,6 +192,8 @@ public class QRDisplayDialog extends Dialog<UR> {
}
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
@@ -235,11 +244,11 @@ public class QRDisplayDialog extends Dialog<UR> {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
protected static Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
@@ -10,15 +10,14 @@ import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.address.P2SHAddress;
import com.sparrowwallet.drongo.address.P2WPKHAddress;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.Base43;
import com.sparrowwallet.drongo.protocol.ScriptType;
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.Keystore;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.LegacyURDecoder;
import com.sparrowwallet.hummingbird.registry.*;
@@ -186,7 +185,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
String qrtext = qrResult.getText();
Matcher partMatcher = PART_PATTERN.matcher(qrtext);
if(qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) {
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete()));
@@ -257,6 +256,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
BitcoinURI bitcoinURI;
Address address;
ExtendedKey extendedKey;
DeterministicSeed seed;
try {
extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey);
@@ -334,6 +334,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Ignore, not parseable as base43 decoded bytes
}
try {
seed = SeedQR.getSeed(qrtext);
result = new Result(seed);
return;
} catch(Exception e) {
//Ignore, not parseable as a SeedQR
}
try {
seed = SeedQR.getSeed(qrResult.getRawBytes());
result = new Result(seed);
return;
} catch(Exception e) {
//Ignore, not parseable as a CompactSeedQR
}
result = new Result(qrtext);
}
}
@@ -401,6 +417,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
CryptoAccount cryptoAccount = (CryptoAccount)ur.decodeFromRegistry();
List<Wallet> wallets = getWallets(cryptoAccount);
return new Result(wallets);
} else if(urRegistryType.equals(RegistryType.CRYPTO_SEED)) {
CryptoSeed cryptoSeed = (CryptoSeed)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoSeed);
return new Result(seed);
} else if(urRegistryType.equals(RegistryType.CRYPTO_BIP39)) {
CryptoBip39 cryptoBip39 = (CryptoBip39)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoBip39);
return new Result(seed);
} else {
log.error("Unsupported UR type " + urRegistryType);
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
@@ -523,6 +547,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
return null;
}
private DeterministicSeed getSeed(CryptoSeed cryptoSeed) {
return new DeterministicSeed(cryptoSeed.getSeed(), null, cryptoSeed.getBirthdate() == null ? System.currentTimeMillis() : cryptoSeed.getBirthdate().getTime());
}
private DeterministicSeed getSeed(CryptoBip39 cryptoBip39) {
return new DeterministicSeed(cryptoBip39.getWords(), null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
}
}
private class QRScanListener implements WebcamListener {
@@ -626,6 +658,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final ExtendedKey extendedKey;
public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets;
public final DeterministicSeed seed;
public final String payload;
public final Throwable exception;
@@ -636,6 +669,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -647,6 +681,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -658,6 +693,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -669,6 +705,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -680,6 +717,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = extendedKey;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -691,6 +729,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = outputDescriptor;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -702,6 +741,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = wallets;
this.seed = null;
this.payload = null;
this.exception = null;
}
public Result(DeterministicSeed seed) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = seed;
this.payload = null;
this.exception = null;
}
@@ -713,6 +765,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = payload;
this.exception = null;
}
@@ -724,6 +777,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = exception;
}
@@ -20,6 +20,7 @@ import tornadofx.control.Form;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class SearchWalletDialog extends Dialog<Entry> {
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
@@ -73,7 +74,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
results = new CoinTreeTable();
results.setShowRoot(false);
results.setPrefWidth(showWallet ? 950 : 850);
results.setBitcoinUnit(walletForms.iterator().next().getWallet());
results.setUnitFormat(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results"));
@@ -134,7 +135,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
});
search.textProperty().addListener((observable, oldValue, newValue) -> {
searchWallet(newValue.toLowerCase());
searchWallet(newValue.toLowerCase(Locale.ROOT));
});
setResizable(true);
@@ -159,7 +160,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
for(Entry entry : walletTransactionsEntry.getChildren()) {
if(entry instanceof TransactionEntry transactionEntry) {
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase().contains(searchText)) ||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
@@ -170,8 +171,8 @@ public class SearchWalletDialog extends Dialog<Entry> {
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().contains(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase().contains(searchText)) ||
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);
}
@@ -184,8 +185,8 @@ public class SearchWalletDialog extends Dialog<Entry> {
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().contains(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase().contains(searchText)) ||
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);
}
@@ -197,8 +198,8 @@ 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().equals(searchText) ||
(hashIndexEntry.getLabel() != null && hashIndexEntry.getLabel().toLowerCase().contains(searchText)) ||
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);
}
@@ -14,6 +14,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
@@ -47,7 +48,19 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
List<Payment> initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList());
Grid grid = getGrid(initialPayments);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView = new SpreadsheetView(grid) {
@Override
public void pasteClipboard() {
final Clipboard clipboard = Clipboard.getSystemClipboard();
if(clipboard.hasString()) {
final TablePosition<?,?> tp = getSelectionModel().getFocusedCell();
SpreadsheetCell cell = getGrid().getRows().get(tp.getRow()).get(tp.getColumn());
getGrid().setCellValue(cell.getRow(), cell.getColumn(), cell.getCellType().convertValue(clipboard.getString()));
} else {
super.pasteClipboard();
}
}
};
spreadsheetView.getColumns().get(0).setPrefWidth(400);
spreadsheetView.getColumns().get(1).setPrefWidth(150);
spreadsheetView.getColumns().get(2).setPrefWidth(247);
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.beans.property.*;
import javafx.concurrent.Worker;
import javafx.scene.control.DialogPane;
import javafx.scene.image.Image;
@@ -22,4 +23,117 @@ public class ServiceProgressDialog extends ProgressDialog {
Image image = new Image(imagePath);
dialogPane.setGraphic(new ImageView(image));
}
public static class ProxyWorker implements Worker<Boolean> {
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", State.READY);
private final StringProperty message = new SimpleStringProperty(this, "message", "");
private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress", -1);
public void start() {
state.set(State.SCHEDULED);
}
public void end() {
state.set(State.SUCCEEDED);
}
@Override
public State getState() {
return state.get();
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
@Override
public Boolean getValue() {
return Boolean.TRUE;
}
@Override
public ReadOnlyObjectProperty<Boolean> valueProperty() {
return null;
}
@Override
public Throwable getException() {
return null;
}
@Override
public ReadOnlyObjectProperty<Throwable> exceptionProperty() {
return null;
}
@Override
public double getWorkDone() {
return 0;
}
@Override
public ReadOnlyDoubleProperty workDoneProperty() {
return null;
}
@Override
public double getTotalWork() {
return 0;
}
@Override
public ReadOnlyDoubleProperty totalWorkProperty() {
return null;
}
@Override
public double getProgress() {
return progress.get();
}
@Override
public ReadOnlyDoubleProperty progressProperty() {
return progress;
}
@Override
public boolean isRunning() {
return false;
}
@Override
public ReadOnlyBooleanProperty runningProperty() {
return null;
}
@Override
public String getMessage() {
return message.get();
}
public void setMessage(String strMessage) {
message.set(strMessage);
}
@Override
public ReadOnlyStringProperty messageProperty() {
return message;
}
@Override
public String getTitle() {
return null;
}
@Override
public ReadOnlyStringProperty titleProperty() {
return null;
}
@Override
public boolean cancel() {
return false;
}
}
}
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Theme;
@@ -20,22 +21,25 @@ import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseEvent;
import javafx.scene.control.MenuItem;
import javafx.scene.image.Image;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Line;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
@@ -44,7 +48,12 @@ import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import javax.imageio.ImageIO;
import java.awt.image.*;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
public class TransactionDiagram extends GridPane {
@@ -66,13 +75,14 @@ public class TransactionDiagram extends GridPane {
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
private boolean expanded;
private TransactionDiagram expandedDiagram;
private ContextMenu contextMenu;
private final EventHandler<MouseEvent> expandedDiagramHandler = new EventHandler<>() {
@Override
public void handle(MouseEvent event) {
if(!event.isConsumed()) {
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle(walletTx.getPayments().iterator().next().getLabel());
stage.setTitle(getDiagramTitle());
stage.initOwner(TransactionDiagram.this.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
@@ -95,7 +105,8 @@ public class TransactionDiagram extends GridPane {
expandedDiagram = new TransactionDiagram();
expandedDiagram.setId("transactionDiagram");
expandedDiagram.setExpanded(true);
updateExpandedDiagram();
expandedDiagram.setFinal(isFinal());
updateDerivedDiagram(expandedDiagram);
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER_RIGHT);
@@ -122,6 +133,13 @@ public class TransactionDiagram extends GridPane {
}
};
private final EventHandler<ContextMenuEvent> contextMenuHandler = new EventHandler<>() {
@Override
public void handle(ContextMenuEvent event) {
contextMenu.show(getChildren().iterator().next(), event.getScreenX(), event.getScreenY());
}
};
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
setMaxHeight(getDiagramHeight());
@@ -133,7 +151,7 @@ public class TransactionDiagram extends GridPane {
update();
setOnMouseClicked(expandedDiagramHandler);
if(expandedDiagram != null) {
updateExpandedDiagram();
updateDerivedDiagram(expandedDiagram);
}
}
}
@@ -162,20 +180,22 @@ public class TransactionDiagram extends GridPane {
getChildren().clear();
}
private void updateExpandedDiagram() {
expandedDiagram.setFinal(isFinal());
expandedDiagram.setOptimizationStrategy(getOptimizationStrategy());
expandedDiagram.walletTx = walletTx;
private void updateDerivedDiagram(TransactionDiagram diagram) {
diagram.setOptimizationStrategy(getOptimizationStrategy());
diagram.walletTx = walletTx;
List<Map<BlockTransactionHashIndex, WalletNode>> utxoSets = expandedDiagram.getDisplayedUtxoSets();
int maxSetSize = utxoSets.stream().mapToInt(Map::size).max().orElse(0);
int maxRows = Math.max(maxSetSize * utxoSets.size(), walletTx.getPayments().size() + 2);
double diagramHeight = Math.max(DIAGRAM_HEIGHT, Math.min(EXPANDED_DIAGRAM_HEIGHT, maxRows * ROW_HEIGHT));
expandedDiagram.setMinHeight(diagramHeight);
expandedDiagram.setMaxHeight(diagramHeight);
expandedDiagram.update();
if(diagram.isExpanded()) {
List<Map<BlockTransactionHashIndex, WalletNode>> utxoSets = diagram.getDisplayedUtxoSets();
int maxSetSize = utxoSets.stream().mapToInt(Map::size).max().orElse(0);
int maxRows = Math.max(maxSetSize * utxoSets.size(), walletTx.getPayments().size() + 2);
double diagramHeight = Math.max(DIAGRAM_HEIGHT, Math.min(EXPANDED_DIAGRAM_HEIGHT, maxRows * ROW_HEIGHT));
diagram.setMinHeight(diagramHeight);
diagram.setMaxHeight(diagramHeight);
}
if(expandedDiagram.getScene() != null && expandedDiagram.getScene().getWindow() instanceof Stage stage) {
diagram.update();
if(diagram.getScene() != null && diagram.getScene().getWindow() instanceof Stage stage) {
stage.sizeToScene();
}
}
@@ -205,6 +225,17 @@ public class TransactionDiagram extends GridPane {
getChildren().clear();
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
if(contextMenu == null) {
contextMenu = new ContextMenu();
MenuItem menuItem = new MenuItem("Save as Image...");
menuItem.setOnAction(event -> {
contextMenu.hide();
saveAsImage();
});
contextMenu.getItems().add(menuItem);
setOnContextMenuRequested(contextMenuHandler);
}
}
private List<Map<BlockTransactionHashIndex, WalletNode>> getDisplayedUtxoSets() {
@@ -420,6 +451,9 @@ public class TransactionDiagram extends GridPane {
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
}
ContextMenu contextMenu = new LabelContextMenu(walletNode.getAddress(), inputValue);
label.setContextMenu(contextMenu);
} else {
if(input instanceof PayjoinBlockTransactionHashIndex) {
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
@@ -428,7 +462,7 @@ public class TransactionDiagram extends GridPane {
StringJoiner joiner = new StringJoiner("\n");
joiner.add("Spending " + getSatsValue(inputValue) + " sats from" + (isExpanded() ? ":" : " (click to expand):"));
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(additionalInput.getHashAsString() + ":" + additionalInput.getIndex());
joiner.add(getInputDescription(additionalInput));
}
tooltip.setText(joiner.toString());
} else if(input instanceof InvisibleBlockTransactionHashIndex) {
@@ -448,6 +482,9 @@ public class TransactionDiagram extends GridPane {
Address fromAddress = txOutput.getScript().getToAddress();
inputValue = txOutput.getValue();
tooltip.setText("Input of " + getSatsValue(inputValue) + " sats\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : ""));
ContextMenu contextMenu = new LabelContextMenu(fromAddress, inputValue);
label.setContextMenu(contextMenu);
} else {
tooltip.setText(input.getHashAsString() + ":" + input.getIndex());
}
@@ -650,7 +687,7 @@ public class TransactionDiagram extends GridPane {
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = getToWallet(payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null;
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 "
@@ -731,6 +768,11 @@ public class TransactionDiagram extends GridPane {
for(OutputNode outputNode : outputNodes) {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount);
if(!outputNode.outputLabel.getChildren().isEmpty() && outputNode.outputLabel.getChildren().get(0) instanceof Label outputLabelControl) {
outputLabelControl.setContextMenu(contextMenu);
}
}
boolean highFee = (walletTx.getFeePercentage() > 0.1);
@@ -759,6 +801,11 @@ public class TransactionDiagram extends GridPane {
outputsBox.getChildren().add(feeBox);
outputsBox.getChildren().add(createSpacer());
if(walletTx.getFee() >= 0) {
ContextMenu contextMenu = new LabelContextMenu(null, walletTx.getFee());
feeLabel.setContextMenu(contextMenu);
}
return outputsBox;
}
@@ -778,12 +825,51 @@ public class TransactionDiagram extends GridPane {
tooltip.setShowDuration(Duration.INDEFINITE);
tooltip.getStyleClass().add("transaction-tooltip");
txLabel.setTooltip(tooltip);
txPane.getChildren().add(txLabel);
txPane.getChildren().add(createSpacer());
return txPane;
}
private void saveAsImage() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Image");
fileChooser.setInitialFileName(getDiagramTitle() + ".png");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
TransactionDiagram transactionDiagram = new TransactionDiagram();
transactionDiagram.setId("transactionDiagram");
transactionDiagram.setFinal(true);
transactionDiagram.setExpanded(isExpanded());
transactionDiagram.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null)));
transactionDiagram.setStyle("-fx-text-background-color: " + (Config.get().getTheme() == Theme.DARK ? "#ffffff" : "#000000"));
updateDerivedDiagram(transactionDiagram);
Scene scene = new Scene(transactionDiagram);
scene.setFill(Color.TRANSPARENT);
scene.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
scene.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scene.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
Image image = scene.snapshot(null);
BufferedImage bufferedImage = SwingFXUtils.fromFXImage(image, null);
try {
ImageIO.write(bufferedImage, "png", file);
} catch(IOException e) {
AppServices.showErrorDialog("Error saving image", e.getMessage());
}
}
}
private String getDiagramTitle() {
if(!isFinal() && walletTx.getPayments().size() > 0 && walletTx.getPayments().get(0).getLabel() != null) {
return walletTx.getPayments().get(0).getLabel();
} else {
return "[" + walletTx.getTransaction().getTxId().toString().substring(0, 6) + "]";
}
}
public double getDiagramHeight() {
if(isExpanded()) {
return getMaxHeight();
@@ -1194,11 +1280,11 @@ public class TransactionDiagram extends GridPane {
}
private class OutputNode implements Comparable<OutputNode> {
public Node outputLabel;
public Pane outputLabel;
public Address address;
public long amount;
public OutputNode(Node outputLabel, Address address, long amount) {
public OutputNode(Pane outputLabel, Address address, long amount) {
this.outputLabel = outputLabel;
this.address = address;
this.amount = amount;
@@ -1213,4 +1299,44 @@ public class TransactionDiagram extends GridPane {
}
}
}
private static class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(event -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(address.toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyAddress);
MenuItem showAddress = new MenuItem("Show Address as QR...");
showAddress.setOnAction(event -> {
hide();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
qrDisplayDialog.showAndWait();
});
getItems().add(showAddress);
}
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
copySatsValue.setOnAction(event -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(Long.toString(value));
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyBtcValue = new MenuItem("Copy Value in BTC");
copyBtcValue.setOnAction(event -> {
hide();
ClipboardContent content = new ClipboardContent();
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(value));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
}
}
}
@@ -165,8 +165,9 @@ public class TransactionHexArea extends CodeArea {
for(int j = 0; j < witness.getPushes().size(); j++) {
byte[] push = witness.getPushes().get(j);
VarInt witnessLen = new VarInt(push.length);
boolean isSignature = isSignature(push);
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data" + (isSignature ? "-signature" : "")));
}
}
}
@@ -204,6 +205,19 @@ public class TransactionHexArea extends CodeArea {
return "other";
}
private boolean isSignature(byte[] data) {
if(data.length >= 64) {
try {
TransactionSignature.decodeFromBitcoin(data, false);
return true;
} catch(Exception e) {
//ignore, not a signature
}
}
return false;
}
private String describeTransactionPart(Collection<String> styles) {
String style = "";
Integer index = null;
@@ -238,7 +252,7 @@ public class TransactionHexArea extends CodeArea {
case "output-pubkeyscript" -> "Output #" + index + " scriptPubKey";
case "witness-count" -> "Input #" + index + " witness count";
case "witness-length" -> "Input #" + index + " witness #" + witnessIndex + " length";
case "witness-data" -> "Input #" + index + " witness #" + witnessIndex + " data";
case "witness-data", "witness-data-signature" -> "Input #" + index + " witness #" + witnessIndex + " data";
case "locktime" -> "Locktime";
default -> "";
};
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
@@ -8,12 +7,10 @@ import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import java.util.List;
public class TransactionsTreeTable extends CoinTreeTable {
public void initialize(WalletTransactionsEntry rootEntry) {
getStyleClass().add("transactions-treetable");
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
updateAll(rootEntry);
setShowRoot(false);
@@ -58,7 +55,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
}
public void updateAll(WalletTransactionsEntry rootEntry) {
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
setRoot(rootItem);
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
@@ -36,8 +37,7 @@ public class UtxosChart extends BarChart<String, Number> {
getData().add(utxoSeries);
update(walletUtxosEntry);
BitcoinUnit unit = Config.get().getBitcoinUnit();
setBitcoinUnit(walletUtxosEntry.getWallet(), unit);
setUnitFormat(walletUtxosEntry.getWallet(), Config.get().getUnitFormat(), Config.get().getBitcoinUnit());
}
public void update(WalletUtxosEntry walletUtxosEntry) {
@@ -57,6 +57,7 @@ public class UtxosChart extends BarChart<String, Number> {
for(int i = 0; i < utxoDataList.size(); i++) {
XYChart.Data<String, Number> newData = utxoDataList.get(i);
newData.setXValue((i+1) + ". " + newData.getXValue());
if(i < utxoSeries.getData().size()) {
XYChart.Data<String, Number> existingData = utxoSeries.getData().get(i);
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() instanceof Entry && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
@@ -70,7 +71,7 @@ public class UtxosChart extends BarChart<String, Number> {
}
if(utxoSeries.getData().size() > utxoDataList.size()) {
utxoSeries.getData().remove(Math.max(0, utxoDataList.size() - 1), utxoSeries.getData().size());
utxoSeries.getData().remove(utxoDataList.size(), utxoSeries.getData().size());
}
if(selectedEntries != null) {
@@ -116,12 +117,16 @@ public class UtxosChart extends BarChart<String, Number> {
this.selectedEntries = entries;
}
public void setBitcoinUnit(Wallet wallet, BitcoinUnit unit) {
public void setUnitFormat(Wallet wallet, UnitFormat format, BitcoinUnit unit) {
if(format == null) {
format = UnitFormat.DOT;
}
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = wallet.getAutoUnit();
}
NumberAxis yaxis = (NumberAxis)getYAxis();
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, unit));
yaxis.setTickLabelFormatter(new CoinAxisFormatter(yaxis, format, unit));
}
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.SelectionMode;
@@ -8,12 +7,11 @@ import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import java.util.Comparator;
import java.util.List;
public class UtxosTreeTable extends CoinTreeTable {
public void initialize(WalletUtxosEntry rootEntry) {
getStyleClass().add("utxos-treetable");
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
updateAll(rootEntry);
setShowRoot(false);
@@ -24,6 +22,7 @@ public class UtxosTreeTable extends CoinTreeTable {
});
dateCol.setCellFactory(p -> new DateCell());
dateCol.setSortable(true);
dateCol.setComparator(dateCol.getComparator().reversed());
getColumns().add(dateCol);
TreeTableColumn<Entry, Entry> outputCol = new TreeTableColumn<>("Output");
@@ -35,7 +34,12 @@ public class UtxosTreeTable extends CoinTreeTable {
outputCol.setComparator((o1, o2) -> {
UtxoEntry entry1 = (UtxoEntry)o1;
UtxoEntry entry2 = (UtxoEntry)o2;
return entry1.getDescription().compareTo(entry2.getDescription());
int hashCompare = entry1.getHashIndex().getHash().toString().compareTo(entry2.getHashIndex().getHash().toString());
if(hashCompare != 0) {
return hashCompare;
}
return (int)(entry1.getHashIndex().getIndex() - entry2.getHashIndex().getIndex());
});
getColumns().add(outputCol);
@@ -86,7 +90,7 @@ public class UtxosTreeTable extends CoinTreeTable {
}
public void updateAll(WalletUtxosEntry rootEntry) {
setBitcoinUnit(rootEntry.getWallet());
setUnitFormat(rootEntry.getWallet());
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
setRoot(rootItem);
@@ -42,17 +42,19 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new Descriptor(), new SpecterDesktop(), new Sparrow());
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
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());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
Accordion exportAccordion = new Accordion();
for (WalletExport exporter : exporters) {
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
exportAccordion.getPanes().add(exportPane);
for(WalletExport exporter : exporters) {
if(!exporter.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
exportAccordion.getPanes().add(exportPane);
}
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
@@ -49,16 +49,20 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), 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());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
}
}
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
}
}
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
@@ -10,19 +10,28 @@ import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.util.Locale;
public class WalletLabelDialog extends Dialog<String> {
private static final int MAX_LABEL_LENGTH = 25;
private final CustomTextField label;
public WalletLabelDialog(String initialName) {
this(initialName, "Account");
}
public WalletLabelDialog(String initialName, String walletType) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Account Name");
dialogPane.setHeaderText("Enter a name for this account:");
setTitle(walletType + " Name");
dialogPane.setHeaderText("Enter a name for this " + walletType.toLowerCase(Locale.ROOT) + ":");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(400);
@@ -48,17 +57,18 @@ public class WalletLabelDialog extends Dialog<String> {
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(label, Validator.combine(
Validator.createEmptyValidator("Account name is required")
Validator.createEmptyValidator(walletType + " name is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Label too long", newValue != null && newValue.length() > MAX_LABEL_LENGTH)
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename Account", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename " + walletType, ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button)dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0, label.textProperty());
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0 || label.getText().length() > MAX_LABEL_LENGTH, label.textProperty());
okButton.disableProperty().bind(isInvalid);
label.setPromptText("Account Name");
label.setPromptText(walletType + " Name");
Platform.runLater(label::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? label.getText() : null);
}
@@ -84,7 +84,7 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
okButton.setPrefWidth(130);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> passwordConfirm.isVisible() && !password.getText().equals(passwordConfirm.getText()), password.textProperty(), passwordConfirm.textProperty());
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> (requirement == PasswordRequirement.LOAD && password.getText().isEmpty()) || (passwordConfirm.isVisible() && !password.getText().equals(passwordConfirm.getText())), password.textProperty(), passwordConfirm.textProperty());
okButton.disableProperty().bind(isInvalid);
if(requirement != PasswordRequirement.UPDATE_NEW && requirement != PasswordRequirement.UPDATE_CHANGE) {
@@ -1,11 +1,19 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
public class BitcoinUnitChangedEvent {
public class BitcoinUnitChangedEvent extends UnitFormatChangedEvent {
private final BitcoinUnit bitcoinUnit;
public BitcoinUnitChangedEvent(BitcoinUnit bitcoinUnit) {
super(Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat());
this.bitcoinUnit = bitcoinUnit;
}
public BitcoinUnitChangedEvent(UnitFormat unitFormat, BitcoinUnit bitcoinUnit) {
super(unitFormat);
this.bitcoinUnit = bitcoinUnit;
}
@@ -1,10 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
@@ -13,7 +12,6 @@ import com.sparrowwallet.sparrow.wallet.TransactionHashIndexEntry;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
public class NewWalletTransactionsEvent {
@@ -50,16 +48,21 @@ public class NewWalletTransactionsEvent {
}
public String getValueAsText(long value) {
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
format = UnitFormat.DOT;
}
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
}
if(unit == BitcoinUnit.BTC) {
return CoinLabel.getBTCFormat().format((double) value / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
return format.formatBtcValue(value) + " BTC";
}
return String.format(Locale.ENGLISH, "%,d", value) + " sats";
return format.formatSatsValue(value) + " sats";
}
public List<BlockTransaction> getUnspentConfirmingWhirlpoolMixTransactions() {
@@ -8,10 +8,16 @@ import java.util.List;
public class SendActionEvent extends FunctionActionEvent {
private final List<BlockTransactionHashIndex> utxos;
private final boolean selectIfEmpty;
public SendActionEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, false);
}
public SendActionEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, boolean selectIfEmpty) {
super(Function.SEND, wallet);
this.utxos = utxos;
this.selectIfEmpty = selectIfEmpty;
}
public List<BlockTransactionHashIndex> getUtxos() {
@@ -20,6 +26,6 @@ public class SendActionEvent extends FunctionActionEvent {
@Override
public boolean selectFunction() {
return !getUtxos().isEmpty();
return selectIfEmpty || !getUtxos().isEmpty();
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class ShowTransactionsCountEvent {
private final boolean showCount;
public ShowTransactionsCountEvent(boolean showCount) {
this.showCount = showCount;
}
public boolean isShowCount() {
return showCount;
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -15,19 +16,21 @@ public class SpendUtxoEvent {
private final Long fee;
private final boolean includeSpentMempoolOutputs;
private final Pool pool;
private final PaymentCode paymentCode;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, false);
this(wallet, utxos, null, null, null, false);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeSpentMempoolOutputs) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean includeSpentMempoolOutputs) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.opReturns = null;
this.opReturns = opReturns;
this.fee = fee;
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
this.pool = null;
this.paymentCode = null;
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, Pool pool) {
@@ -38,6 +41,18 @@ public class SpendUtxoEvent {
this.fee = fee;
this.includeSpentMempoolOutputs = false;
this.pool = pool;
this.paymentCode = null;
}
public SpendUtxoEvent(Wallet wallet, List<Payment> payments, List<byte[]> opReturns, PaymentCode paymentCode) {
this.wallet = wallet;
this.utxos = null;
this.payments = payments;
this.opReturns = opReturns;
this.fee = null;
this.includeSpentMempoolOutputs = false;
this.pool = null;
this.paymentCode = paymentCode;
}
public Wallet getWallet() {
@@ -67,4 +82,8 @@ public class SpendUtxoEvent {
public Pool getPool() {
return pool;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionFetchFailedEvent extends TransactionReferencesFailedEvent {
public TransactionFetchFailedEvent(Transaction transaction, Throwable exception) {
super(transaction, exception);
}
public TransactionFetchFailedEvent(Transaction transaction, Throwable exception, int pageStart, int pageEnd) {
super(transaction, exception, pageStart, pageEnd);
}
}
@@ -0,0 +1,21 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
public class UnitFormatChangedEvent {
private final UnitFormat unitFormat;
public UnitFormatChangedEvent(UnitFormat unitFormat) {
this.unitFormat = unitFormat;
}
public UnitFormat getUnitFormat() {
return unitFormat;
}
public BitcoinUnit getBitcoinUnit() {
return Config.get().getBitcoinUnit();
}
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
@@ -7,10 +8,19 @@ import javafx.stage.Window;
public class ViewTransactionEvent {
private final Window window;
private final Transaction transaction;
private final BlockTransaction blockTransaction;
private final TransactionView initialView;
private final Integer initialIndex;
public ViewTransactionEvent(Window window, Transaction transaction) {
this.window = window;
this.transaction = transaction;
this.blockTransaction = null;
this.initialView = TransactionView.HEADERS;
this.initialIndex = null;
}
public ViewTransactionEvent(Window window, BlockTransaction blockTransaction) {
this(window, blockTransaction, TransactionView.HEADERS, null);
}
@@ -21,6 +31,7 @@ public class ViewTransactionEvent {
public ViewTransactionEvent(Window window, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
this.window = window;
this.transaction = blockTransaction.getTransaction();
this.blockTransaction = blockTransaction;
this.initialView = initialView;
this.initialIndex = initialIndex;
@@ -30,6 +41,10 @@ public class ViewTransactionEvent {
return window;
}
public Transaction getTransaction() {
return transaction;
}
public BlockTransaction getBlockTransaction() {
return blockTransaction;
}
@@ -45,10 +45,10 @@ public class WalletHistoryChangedEvent extends WalletChangedEvent {
}
public List<WalletNode> getReceiveNodes() {
return getWallet().getNode(KeyPurpose.RECEIVE).getChildren().stream().filter(historyChangedNodes::contains).collect(Collectors.toList());
return historyChangedNodes.stream().filter(node -> node.getKeyPurpose() == KeyPurpose.RECEIVE).collect(Collectors.toList());
}
public List<WalletNode> getChangeNodes() {
return getWallet().getNode(KeyPurpose.CHANGE).getChildren().stream().filter(historyChangedNodes::contains).collect(Collectors.toList());
return historyChangedNodes.stream().filter(node -> node.getKeyPurpose() == KeyPurpose.CHANGE).collect(Collectors.toList());
}
}
@@ -38,6 +38,7 @@ public class FontAwesome5 extends GlyphFont {
FEATHER_ALT('\uf56b'),
FILE_CSV('\uf6dd'),
FILE_IMPORT('\uf56f'),
FILE_PDF('\uf1c1'),
HAND_HOLDING('\uf4bd'),
HAND_HOLDING_MEDICAL('\ue05c'),
HAND_HOLDING_WATER('\uf4c1'),
@@ -67,6 +68,7 @@ public class FontAwesome5 extends GlyphFont {
SNOWFLAKE('\uf2dc'),
SORT_NUMERIC_DOWN('\uf162'),
SUN('\uf185'),
TAG('\uf02b'),
THEATER_MASKS('\uf630'),
TIMES_CIRCLE('\uf057'),
TOGGLE_OFF('\uf204'),
@@ -53,7 +53,12 @@ public class CaravanMultisig implements WalletImport, WalletExport {
ScriptType scriptType = ScriptType.valueOf(cf.addressType.replace('-', '_'));
for(ExtPublicKey extKey : cf.extendedPublicKeys) {
Keystore keystore = new Keystore(extKey.name);
Keystore keystore = new Keystore(extKey.name.length() > Keystore.MAX_LABEL_LENGTH ? extKey.name.substring(0, Keystore.MAX_LABEL_LENGTH) : extKey.name);
if("Unknown".equals(extKey.bip32Path)) {
extKey.bip32Path = "m/45'/0/0/0";
}
try {
keystore.setKeyDerivation(new KeyDerivation(extKey.xfp, extKey.bip32Path));
} catch(NumberFormatException e) {
@@ -70,6 +75,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
keystore.setWalletModel(walletModel);
keystore.setSource(KeystoreSource.HW_USB);
}
wallet.makeLabelsUnique(keystore);
wallet.getKeystores().add(keystore);
}
@@ -28,7 +28,7 @@ public class CoboVaultMultisig extends ColdcardMultisig {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Cobo Vault.";
}
@@ -67,4 +67,9 @@ public class CoboVaultMultisig extends ColdcardMultisig {
public boolean isWalletExportScannable() {
return true;
}
@Override
public boolean isDeprecated() {
return true;
}
}
@@ -14,6 +14,7 @@ import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
private static final Logger log = LoggerFactory.getLogger(CoboVaultSinglesig.class);
@@ -24,7 +25,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the My Cobo Vault > ... > Export Wallet feature on your Cobo Vault.";
}
@@ -47,7 +48,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
keystore.setLabel(getName());
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setWalletModel(WalletModel.COBO_VAULT);
keystore.setKeyDerivation(new KeyDerivation(coboKeystore.MasterFingerprint.toLowerCase(), "m/" + coboKeystore.AccountKeyPath));
keystore.setKeyDerivation(new KeyDerivation(coboKeystore.MasterFingerprint.toLowerCase(Locale.ROOT), "m/" + coboKeystore.AccountKeyPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(coboKeystore.ExtPubKey));
ExtendedKey.Header header = ExtendedKey.Header.fromExtendedKey(coboKeystore.ExtPubKey);
@@ -101,6 +102,11 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
return true;
}
@Override
public boolean isDeprecated() {
return true;
}
private static class CoboVaultSinglesigKeystore {
public String ExtPubKey;
public String MasterFingerprint;
@@ -18,6 +18,7 @@ import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public class ColdcardMultisig implements WalletImport, KeystoreFileImport, WalletExport {
@@ -84,8 +85,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB > 0 feature on your Coldcard.";
public String getKeystoreImportDescription(int account) {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB > " + account + " feature on your Coldcard.";
}
@Override
@@ -186,7 +187,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.append("# " + getName() + " setup file (created by Sparrow)\n");
writer.append("#\n");
writer.append("Name: ").append(wallet.getFullName()).append("\n");
writer.append("Name: ").append(wallet.getFullName().length() >= 20 ? (wallet.getDisplayName().length() >= 20 ? wallet.getDisplayName().substring(0, 20) : wallet.getDisplayName()) : wallet.getFullName()).append("\n");
writer.append("Policy: ").append(Integer.toString(wallet.getDefaultPolicy().getNumSignaturesRequired())).append(" of ").append(Integer.toString(wallet.getKeystores().size())).append("\n");
if(!multipleDerivations) {
writer.append("Derivation: ").append(wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath()).append("\n");
@@ -198,7 +199,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
if(multipleDerivations) {
writer.append("Derivation: ").append(keystore.getKeyDerivation().getDerivationPath()).append("\n");
}
writer.append(keystore.getKeyDerivation().getMasterFingerprint().toUpperCase()).append(": ").append(keystore.getExtendedPublicKey().toString()).append("\n");
writer.append(keystore.getKeyDerivation().getMasterFingerprint().toUpperCase(Locale.ROOT)).append(": ").append(keystore.getExtendedPublicKey().toString()).append("\n");
if(multipleDerivations) {
writer.append("\n");
}
@@ -213,7 +214,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getWalletExportDescription() {
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from SD feature.";
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from File feature.";
}
@Override
@@ -17,6 +17,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Map;
public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
@@ -28,8 +29,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON > 0 feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
public String getKeystoreImportDescription(int account) {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON > " + account + " feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
}
@Override
@@ -71,7 +72,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
ColdcardKeystore ck = gson.fromJson(map.get(key), ColdcardKeystore.class);
if(ck.name != null) {
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").replace("p2sh-p2wpkh", "p2sh_p2wpkh").toUpperCase());
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").replace("p2sh-p2wpkh", "p2sh_p2wpkh").toUpperCase(Locale.ROOT));
if(ckScriptType.equals(scriptType)) {
Keystore keystore = new Keystore();
keystore.setLabel(getName());
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.net.*;
@@ -15,8 +16,10 @@ import java.lang.reflect.Type;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.ENUMERATE_HW_PERIOD_SECS;
import static com.sparrowwallet.sparrow.net.PagedBatchRequestBuilder.DEFAULT_PAGE_SIZE;
import static com.sparrowwallet.sparrow.net.TcpTransport.DEFAULT_MAX_TIMEOUT;
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SATS;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
@@ -25,6 +28,7 @@ public class Config {
private Mode mode;
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
private FeeRatesSource feeRatesSource;
private FeeRatesSelection feeRatesSelection;
private OptimizationStrategy sendOptimizationStrategy;
@@ -41,21 +45,25 @@ public class Config {
private boolean hideEmptyUsedAddresses = false;
private boolean showTransactionHex = true;
private boolean showLoadingLog = true;
private boolean showAddressTransactionCount = false;
private boolean showDeprecatedImportExport = false;
private boolean preventSleep = false;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private File hwi;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private Boolean hdCapture;
private String webcamDevice;
private ServerType serverType;
private String publicElectrumServer;
private String coreServer;
private List<String> recentCoreServers;
private Server publicElectrumServer;
private Server coreServer;
private List<Server> recentCoreServers;
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private String electrumServer;
private List<String> recentElectrumServers;
private Server electrumServer;
private List<Server> recentElectrumServers;
private File electrumServerCert;
private boolean useProxy;
private String proxyServer;
@@ -72,6 +80,8 @@ public class Config {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(File.class, new FileSerializer());
gsonBuilder.registerTypeAdapter(File.class, new FileDeserializer());
gsonBuilder.registerTypeAdapter(Server.class, new ServerSerializer());
gsonBuilder.registerTypeAdapter(Server.class, new ServerDeserializer());
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
@@ -126,6 +136,15 @@ public class Config {
flush();
}
public UnitFormat getUnitFormat() {
return unitFormat;
}
public void setUnitFormat(UnitFormat unitFormat) {
this.unitFormat = unitFormat;
flush();
}
public FeeRatesSource getFeeRatesSource() {
return feeRatesSource;
}
@@ -274,6 +293,24 @@ public class Config {
flush();
}
public boolean isShowAddressTransactionCount() {
return showAddressTransactionCount;
}
public void setShowAddressTransactionCount(boolean showAddressTransactionCount) {
this.showAddressTransactionCount = showAddressTransactionCount;
flush();
}
public boolean isShowDeprecatedImportExport() {
return showDeprecatedImportExport;
}
public void setShowDeprecatedImportExport(boolean showDeprecatedImportExport) {
this.showDeprecatedImportExport = showDeprecatedImportExport;
flush();
}
public boolean isPreventSleep() {
return preventSleep;
}
@@ -300,6 +337,10 @@ public class Config {
flush();
}
public long getDustAttackThreshold() {
return dustAttackThreshold;
}
public File getHwi() {
return hwi;
}
@@ -309,6 +350,10 @@ public class Config {
flush();
}
public int getEnumerateHwPeriod() {
return enumerateHwPeriod;
}
public Boolean getHdCapture() {
return hdCapture;
}
@@ -340,14 +385,18 @@ public class Config {
flush();
}
public boolean hasServerAddress() {
return getServerAddress() != null && !getServerAddress().isEmpty();
public boolean hasServer() {
return getServer() != null;
}
public String getServerAddress() {
public Server getServer() {
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : (getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? getPublicElectrumServer() : getElectrumServer());
}
public String getServerDisplayName() {
return getServer() == null ? "server" : getServer().getDisplayName();
}
public boolean requiresInternalTor() {
if(isUseProxy()) {
return false;
@@ -357,57 +406,71 @@ public class Config {
}
public boolean requiresTor() {
if(!hasServerAddress()) {
if(!hasServer()) {
return false;
}
Protocol protocol = Protocol.getProtocol(getServerAddress());
if(protocol == null) {
return false;
}
return protocol.isOnionAddress(protocol.getServerHostAndPort(getServerAddress()));
return getServer().isOnionAddress();
}
public String getPublicElectrumServer() {
public Server getPublicElectrumServer() {
return publicElectrumServer;
}
public void setPublicElectrumServer(String publicElectrumServer) {
public void setPublicElectrumServer(Server publicElectrumServer) {
this.publicElectrumServer = publicElectrumServer;
flush();
}
public void changePublicServer() {
List<String> otherServers = PublicElectrumServer.getServers().stream().map(PublicElectrumServer::getUrl).filter(url -> !url.equals(getPublicElectrumServer())).collect(Collectors.toList());
List<Server> otherServers = PublicElectrumServer.getServers().stream().map(PublicElectrumServer::getServer).filter(server -> !server.equals(getPublicElectrumServer())).collect(Collectors.toList());
if(!otherServers.isEmpty()) {
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
}
}
public String getCoreServer() {
public Server getCoreServer() {
return coreServer;
}
public void setCoreServer(String coreServer) {
public void setCoreServer(Server coreServer) {
this.coreServer = coreServer;
flush();
}
public List<String> getRecentCoreServers() {
return recentCoreServers;
public List<Server> getRecentCoreServers() {
return recentCoreServers == null ? new ArrayList<>() : recentCoreServers;
}
public void addRecentCoreServer(String coreServer) {
public boolean addRecentCoreServer(Server coreServer) {
if(recentCoreServers == null) {
recentCoreServers = new ArrayList<>();
}
if(!recentCoreServers.contains(coreServer)) {
recentCoreServers.stream().filter(url -> Objects.equals(Protocol.getHost(url), Protocol.getHost(coreServer)))
.findFirst().ifPresent(existingUrl -> recentCoreServers.remove(existingUrl));
int index = getRecentCoreServers().indexOf(coreServer);
if(index < 0) {
recentCoreServers.removeIf(server -> server.getHost().equals(coreServer.getHost()) && server.getAlias() == null);
recentCoreServers.add(coreServer);
flush();
return true;
}
return false;
}
public void removeRecentCoreServer(Server server) {
int index = getRecentCoreServers().indexOf(server);
if(index >= 0) {
recentCoreServers.remove(index);
flush();
}
}
public void setCoreServerAlias(Server server) {
int index = getRecentCoreServers().indexOf(server);
if(index >= 0) {
recentCoreServers.set(index, server);
flush();
}
}
@@ -438,37 +501,59 @@ public class Config {
flush();
}
public String getElectrumServer() {
public Server getElectrumServer() {
return electrumServer;
}
public void setElectrumServer(String electrumServer) {
public void setElectrumServer(Server electrumServer) {
this.electrumServer = electrumServer;
flush();
}
public List<String> getRecentElectrumServers() {
return recentElectrumServers;
public List<Server> getRecentElectrumServers() {
return recentElectrumServers == null ? new ArrayList<>() : recentElectrumServers;
}
public void addRecentServer() {
public boolean addRecentServer() {
if(serverType == ServerType.BITCOIN_CORE && coreServer != null) {
addRecentCoreServer(coreServer);
return addRecentCoreServer(coreServer);
} else if(serverType == ServerType.ELECTRUM_SERVER && electrumServer != null) {
addRecentElectrumServer(electrumServer);
return addRecentElectrumServer(electrumServer);
}
return false;
}
public void addRecentElectrumServer(String electrumServer) {
public boolean addRecentElectrumServer(Server electrumServer) {
if(recentElectrumServers == null) {
recentElectrumServers = new ArrayList<>();
}
if(!recentElectrumServers.contains(electrumServer)) {
recentElectrumServers.stream().filter(url -> Objects.equals(Protocol.getHost(url), Protocol.getHost(electrumServer)))
.findFirst().ifPresent(existingUrl -> recentElectrumServers.remove(existingUrl));
int index = getRecentElectrumServers().indexOf(electrumServer);
if(index < 0) {
recentElectrumServers.removeIf(server -> server.getHost().equals(electrumServer.getHost()) && server.getAlias() == null);
recentElectrumServers.add(electrumServer);
flush();
return true;
}
return false;
}
public void removeRecentElectrumServer(Server server) {
int index = getRecentElectrumServers().indexOf(server);
if(index >= 0) {
recentElectrumServers.remove(index);
flush();
}
}
public void setElectrumServerAlias(Server server) {
int index = getRecentElectrumServers().indexOf(server);
if(index >= 0) {
recentElectrumServers.set(index, server);
flush();
}
}
@@ -573,4 +658,18 @@ public class Config {
return new File(json.getAsJsonPrimitive().getAsString());
}
}
private static class ServerSerializer implements JsonSerializer<Server> {
@Override
public JsonElement serialize(Server src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class ServerDeserializer implements JsonDeserializer<Server> {
@Override
public Server deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Server.fromString(json.getAsJsonPrimitive().getAsString());
}
}
}
@@ -66,7 +66,18 @@ public class Descriptor implements WalletImport, WalletExport {
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
String outputDescriptor = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
inputStream.transferTo(baos);
InputStream firstClone = new ByteArrayInputStream(baos.toByteArray());
InputStream secondClone = new ByteArrayInputStream(baos.toByteArray());
try {
return PdfUtils.getOutputDescriptor(firstClone).toWallet();
} catch(Exception e) {
//ignore
}
String outputDescriptor = new BufferedReader(new InputStreamReader(secondClone, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor.trim());
return descriptor.toWallet();
} catch(Exception e) {
@@ -35,7 +35,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import a single keystore from an Electrum wallet (use File > Import > Electrum to import a multisig wallet).";
}
@@ -83,7 +83,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
if(ek.root_fingerprint == null && ek.ckcc_xfp != null) {
byte[] le = new byte[4];
Utils.uint32ToByteArrayLE(Long.parseLong(ek.ckcc_xfp), le, 0);
ek.root_fingerprint = Utils.bytesToHex(le).toUpperCase();
ek.root_fingerprint = Utils.bytesToHex(le).toUpperCase(Locale.ROOT);
}
ew.keystores.put(key, ek);
}
@@ -95,6 +95,10 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
}
if(key.equals("gap_limit") && map.get(key) instanceof JsonPrimitive gapLimit) {
ew.gap_limit = gapLimit.getAsInt();
}
if(key.equals("addresses")) {
ew.addresses = gson.fromJson(map.get(key), ElectrumAddresses.class);
}
@@ -189,6 +193,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
keystore.setLabel(keystore.getLabel().substring(0, Keystore.MAX_LABEL_LENGTH));
}
wallet.makeLabelsUnique(keystore);
wallet.getKeystores().add(keystore);
ExtendedKey.Header xpubHeader = ExtendedKey.Header.fromExtendedKey(ek.xpub);
@@ -209,6 +214,10 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
throw new ImportException("Unknown Electrum wallet type of " + ew.wallet_type);
}
if(ew.gap_limit != null) {
wallet.setGapLimit(ew.gap_limit);
}
for(String key : ew.labels.keySet()) {
try {
Sha256Hash txHash = Sha256Hash.wrap(key);
@@ -408,6 +417,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
public String seed_type;
public Boolean use_encryption;
public ElectrumAddresses addresses;
public Integer gap_limit;
public Map<String, String> labels = new LinkedHashMap<>();
public Map<Sha256Hash, BlockTransaction> transactions = new HashMap<>();
}
@@ -0,0 +1,136 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
public class ElectrumPersonalServer implements WalletExport {
private static final Logger log = LoggerFactory.getLogger(ElectrumPersonalServer.class);
@Override
public String getName() {
return "Electrum Personal Server";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.EPS;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
if(wallet.getScriptType() == ScriptType.P2TR) {
throw new ExportException(getName() + " does not support Taproot wallets.");
}
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.write("# Electrum Personal Server configuration file fragments\n");
writer.write("# Copy the lines below into the relevant sections in your EPS config.ini file\n\n");
writer.write("# Copy into [master-public-keys] section\n");
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
writeWalletXpub(masterWallet, writer);
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isNested()) {
writeWalletXpub(childWallet, writer);
}
}
writeBip47Addresses(masterWallet, writer);
writer.flush();
} catch(Exception e) {
log.error("Could not export EPS wallet", e);
throw new ExportException("Could not export EPS wallet", e);
}
}
private static void writeWalletXpub(Wallet wallet, BufferedWriter writer) throws IOException {
writer.write(wallet.getFullName().replace(' ', '_') + " = ");
ExtendedKey.Header xpubHeader = ExtendedKey.Header.fromScriptType(wallet.getScriptType(), false);
if(wallet.getPolicyType() == PolicyType.MULTI) {
writer.write(wallet.getDefaultPolicy().getNumSignaturesRequired() + " ");
}
for(Iterator<Keystore> iter = wallet.getKeystores().iterator(); iter.hasNext(); ) {
Keystore keystore = iter.next();
writer.write(keystore.getExtendedPublicKey().toString(xpubHeader));
if(iter.hasNext()) {
writer.write(" ");
}
}
writer.newLine();
}
private static void writeBip47Addresses(Wallet wallet, BufferedWriter writer) throws IOException {
if(wallet.hasPaymentCode()) {
writer.write("\n# Copy into [watch-only-addresses] section\n");
WalletNode notificationNode = wallet.getNotificationWallet().getNode(KeyPurpose.NOTIFICATION);
writer.write(wallet.getFullName().replace(' ', '_') + "-notification_addr = " + notificationNode.getAddress().toString() + "\n");
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
writer.write(childWallet.getFullName().replace(' ', '_') + " = ");
for(Iterator<KeyPurpose> purposeIterator = KeyPurpose.DEFAULT_PURPOSES.iterator(); purposeIterator.hasNext(); ) {
KeyPurpose keyPurpose = purposeIterator.next();
for(Iterator<WalletNode> iter = childWallet.getNode(keyPurpose).getChildren().iterator(); iter.hasNext(); ) {
WalletNode receiveNode = iter.next();
writer.write(receiveNode.getAddress().toString());
if(iter.hasNext()) {
writer.write(" ");
}
}
if(purposeIterator.hasNext()) {
writer.write(" ");
}
}
writer.newLine();
}
}
writer.write("\n# Important: If this wallet receives any BIP47 payments, redo this export");
}
}
@Override
public String getWalletExportDescription() {
return "Export this wallet as a configuration file fragment to copy into your Electrum Personal Server (EPS) config.ini file.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "ini";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public boolean exportsAllWallets() {
return true;
}
}
@@ -2,6 +2,6 @@ package com.sparrowwallet.sparrow.io;
import java.io.File;
public interface FileImport extends Import {
public interface FileImport extends ImportExport {
boolean isEncrypted(File file);
}
@@ -35,7 +35,7 @@ public class GordianSeedTool implements KeystoreFileImport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Select your seed and scan the QR code created by Authenticate > Derive Key > Other Key Derivations > " + Network.get().toDisplayString() + " > Master Key > Account Descriptor. Click the share icon at the bottom.";
}
@@ -25,6 +25,7 @@ import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -33,7 +34,7 @@ public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.0.2";
private static final String HWI_VERSION = "2.1.1";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -240,32 +241,44 @@ public class Hwi {
}
private String execute(List<String> command) throws IOException {
ProcessBuilder processBuilder = new ProcessBuilder(command);
Process process = processBuilder.start();
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
long start = System.currentTimeMillis();
Process process = null;
try {
ProcessBuilder processBuilder = new ProcessBuilder(command);
process = processBuilder.start();
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
} finally {
deleteExtractionOnFailure(process, start);
}
}
private String execute(List<String> arguments, Command command, String... commandArguments) throws IOException {
List<String> processArguments = new ArrayList<>(arguments);
processArguments.add("--stdin");
long start = System.currentTimeMillis();
Process process = null;
try {
List<String> processArguments = new ArrayList<>(arguments);
processArguments.add("--stdin");
ProcessBuilder processBuilder = new ProcessBuilder(processArguments);
Process process = processBuilder.start();
ProcessBuilder processBuilder = new ProcessBuilder(processArguments);
process = processBuilder.start();
try(BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8))) {
writer.write(command.toString());
for(String commandArgument : commandArguments) {
writer.write(" \"");
writer.write(commandArgument.replace("\\", "\\\\").replace("\"", "\\\""));
writer.write("\"");
try(BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8))) {
writer.write(command.toString());
for(String commandArgument : commandArguments) {
writer.write(" \"");
writer.write(commandArgument.replace("\\", "\\\\").replace("\"", "\\\""));
writer.write("\"");
}
writer.flush();
}
writer.flush();
}
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
} finally {
deleteExtractionOnFailure(process, start);
}
}
@@ -287,6 +300,7 @@ public class Hwi {
if(hwiExecutable == null || !hwiExecutable.exists()) {
try {
Platform platform = Platform.getCurrent();
String osArch = System.getProperty("os.arch");
Set<PosixFilePermission> ownerExecutableWritable = PosixFilePermissions.fromString("rwxr--r--");
//A PyInstaller --onefile expands into a new directory on every run triggering OSX Gatekeeper checks.
@@ -294,7 +308,13 @@ public class Hwi {
//The check will still happen on first invocation, but will not thereafter
//See https://github.com/bitcoin-core/HWI/issues/327 for details
if(platform == Platform.OSX) {
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + HWI_VERSION_DIR + "-mac-amd64-signed.zip");
InputStream inputStream;
if(osArch.equals("aarch64")) {
inputStream = Hwi.class.getResourceAsStream("/native/osx/aarch64/" + HWI_VERSION_DIR + "-mac-aarch64-signed.zip");
} else {
inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + HWI_VERSION_DIR + "-mac-amd64-signed.zip");
}
if(inputStream == null) {
throw new IllegalStateException("Cannot load " + HWI_VERSION_DIR + " from classpath");
}
@@ -336,8 +356,12 @@ public class Hwi {
InputStream inputStream;
Path tempExecPath;
if(platform == Platform.WINDOWS) {
Files.createDirectories(getHwiHomeDir().toPath());
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null);
tempExecPath = Files.createTempFile(getHwiHomeDir().toPath(), HWI_VERSION_DIR, null);
} else if(osArch.equals("aarch64")) {
inputStream = Hwi.class.getResourceAsStream("/native/linux/aarch64/hwi");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
@@ -368,7 +392,7 @@ public class Hwi {
}
private File getHwiHomeDir() {
if(Platform.getCurrent() == Platform.OSX) {
if(Platform.getCurrent() == Platform.OSX || Platform.getCurrent() == Platform.WINDOWS) {
return new File(Storage.getSparrowDir(), HWI_HOME_DIR);
}
@@ -424,6 +448,40 @@ public class Hwi {
return result.get("success").getAsBoolean();
}
private void deleteExtractionOnFailure(Process process, long after) {
try {
if(Platform.getCurrent() != Platform.OSX && process != null && process.waitFor(100, TimeUnit.MILLISECONDS) && process.exitValue() != 0) {
File extraction = getTemporaryExtraction(after);
if(extraction != null) {
IOUtils.deleteDirectory(extraction);
}
}
} catch(Exception e) {
log.debug("Error deleting temporary extraction", e);
}
}
private File getTemporaryExtraction(long after) {
File tmpDir = new File(System.getProperty("java.io.tmpdir"));
if(!tmpDir.exists()) {
return null;
}
File[] tmps = tmpDir.listFiles(file -> {
if(!file.isDirectory() || file.lastModified() < after) {
return false;
}
String name = file.getName();
if(name.length() < 9 || !name.startsWith("_MEI")) {
return false;
}
File hwilib = new File(file, "hwilib");
return hwilib.exists();
});
return tmps == null || tmps.length == 0 ? null : Arrays.stream(tmps).sorted(Comparator.comparingLong(File::lastModified)).findFirst().orElse(null);
}
private List<String> getDeviceArguments(Device device, Command command) throws IOException {
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType()));
addChainType(elements, false);
@@ -702,7 +760,7 @@ public class Hwi {
private static class DeviceModelSerializer implements JsonSerializer<WalletModel> {
@Override
public JsonElement serialize(WalletModel src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString().toLowerCase());
return new JsonPrimitive(src.toString().toLowerCase(Locale.ROOT));
}
}
@@ -711,7 +769,7 @@ public class Hwi {
public WalletModel deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
String modelStr = json.getAsJsonPrimitive().getAsString();
try {
return WalletModel.valueOf(modelStr.toUpperCase());
return WalletModel.valueOf(modelStr.toUpperCase(Locale.ROOT));
} catch(Exception e) {
for(WalletModel model : WalletModel.values()) {
if(modelStr.startsWith(model.getType())) {
@@ -1,5 +1,8 @@
package com.sparrowwallet.sparrow.io;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
@@ -9,16 +12,19 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class IOUtils {
private static final Logger log = LoggerFactory.getLogger(IOUtils.class);
public static FileType getFileType(File file) {
try {
String type = Files.probeContentType(file.toPath());
if(type == null) {
if(file.getName().toLowerCase().endsWith("txn") || file.getName().toLowerCase().endsWith("psbt")) {
if(file.getName().toLowerCase(Locale.ROOT).endsWith("txn") || file.getName().toLowerCase(Locale.ROOT).endsWith("psbt")) {
return FileType.TEXT;
}
@@ -120,4 +126,28 @@ public class IOUtils {
return true;
}
public static boolean secureDelete(File file) {
if(file.exists()) {
long length = file.length();
SecureRandom random = new SecureRandom();
byte[] data = new byte[1024*1024];
random.nextBytes(data);
try(RandomAccessFile raf = new RandomAccessFile(file, "rws")) {
raf.seek(0);
raf.getFilePointer();
int pos = 0;
while(pos < length) {
raf.write(data);
pos += data.length;
}
} catch(IOException e) {
log.warn("Error overwriting file for deletion " + file.getName(), e);
}
return file.delete();
}
return false;
}
}
@@ -1,8 +0,0 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public interface Import {
String getName();
WalletModel getWalletModel();
}
@@ -2,7 +2,10 @@ package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public interface Export {
public interface ImportExport {
String getName();
WalletModel getWalletModel();
default boolean isDeprecated() {
return false;
}
}
@@ -0,0 +1,45 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.File;
import java.io.InputStream;
public class Jade implements KeystoreFileImport {
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public String getName() {
return "Jade";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.JADE;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
throw new ImportException("Failed to detect a valid " + scriptType.getDescription() + " keystore.");
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your Jade by selecting Xpub Export from the Settings menu once you have loaded your seed.";
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
}
@@ -3,6 +3,8 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
@@ -305,6 +307,11 @@ public class JsonPersistence implements Persistence {
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public boolean isClosed() {
return true;
}
@Override
public void close() {
//Nothing required
@@ -326,6 +333,8 @@ public class JsonPersistence implements Persistence {
gsonBuilder.registerTypeAdapter(Date.class, new DateDeserializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionSerializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionDeserializer());
gsonBuilder.registerTypeAdapter(Address.class, new AddressSerializer());
gsonBuilder.registerTypeAdapter(Address.class, new AddressDeserializer());
if(includeWalletSerializers) {
gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeSerializer());
@@ -424,6 +433,24 @@ public class JsonPersistence implements Persistence {
}
}
private static class AddressSerializer implements JsonSerializer<Address> {
@Override
public JsonElement serialize(Address src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class AddressDeserializer implements JsonDeserializer<Address> {
@Override
public Address deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
try {
return Address.fromString(json.getAsJsonPrimitive().getAsString());
} catch(InvalidAddressException e) {
throw new IllegalStateException(e);
}
}
}
private static class KeystoreSerializer implements JsonSerializer<Keystore> {
@Override
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
@@ -28,7 +28,7 @@ public class KeystoneMultisig extends ColdcardMultisig {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Keystone.";
}
@@ -27,7 +27,7 @@ public class KeystoneSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the My Keystone > ... > Export Wallet feature on your Keystone. Make sure to set the Watch-only Wallet to Sparrow in the Settings first.";
}

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