Compare commits

...
323 Commits
Author SHA1 Message Date
Craig Raw bde8fef35e disable rename wallet on transaction tab 2023-11-22 09:17:17 +02:00
Craig Raw 87e7a87e5e exchange source: use closing price where available 2023-11-22 08:12:32 +02:00
Craig Raw 85eb4df7e9 transaction tab: rearrange via transaction tree 2023-11-22 07:59:26 +02:00
Craig Raw bb32a1e7b1 add comment to transactions csv on the approximate nature of the fiat values 2023-11-21 17:59:13 +02:00
Craig Raw f590794589 add qr display (with save to pdf) to output descriptor wallet export options 2023-11-21 10:06:32 +02:00
Craig Raw 1e3ce7eb88 add historical fiat values to transactions csv export 2023-11-21 09:33:47 +02:00
Craig Raw ef3e2ed695 cormorant: handle checking imports and stopping before started 2023-11-20 10:56:06 +02:00
Craig Raw 74c3370277 use fallback fee rate estimates if the connected server returns an error estimating fee rates 2023-11-20 10:29:14 +02:00
Craig Raw 170e7c0abf reduce http error logging 2023-11-17 09:43:22 +02:00
Craig Raw e0486ff4a9 followup 2023-11-16 18:12:19 +02:00
Craig Raw 1d17384152 increase and decrease maximum on fee rate slider where fee rate equals or exceeds current range, set testnet fallback fee rate to 1 sat/vb 2023-11-16 16:58:24 +02:00
Craig Raw aec26d512b add oxt.me as fee rates source 2023-11-16 10:12:41 +02:00
Craig Raw 9e5a6e83d1 set smaller increment amount on fee range slider for key adjustments 2023-11-15 16:22:12 +02:00
Craig Raw eecb90e9b2 cormorant: check wallet gap limit and increase core descriptor range if necessary 2023-11-15 13:29:26 +02:00
Craig Raw b3516063b2 add null safe tests for standard account types 2023-11-15 10:52:05 +02:00
Craig Raw 1d560d6aa6 save mix to wallet index to improve handling of mix out failures 2023-11-15 10:20:58 +02:00
Craig Raw d128401a09 fix transaction diagram output size indicators in transactions with non-address outputs 2023-11-14 15:18:47 +02:00
Craig Raw 9391a397da set dialog minimum height to preferred height (or make resizable) to avoid window sizing bug in some linux environments 2023-11-14 14:29:28 +02:00
Craig Raw a5312374a8 show error dialog on cpfp if no outputs are spendable 2023-11-13 14:37:41 +02:00
Craig Raw c81c42a87c switch from httpurlconnection to jetty http client to avoid spurious dns query 2023-11-10 19:16:03 +02:00
Craig Raw d84ade5b7d add krux to wallet import options 2023-11-10 14:55:00 +02:00
Craig Raw 323d29e34a bump to v1.8.1 2023-11-09 14:37:35 +02:00
Craig Raw 95cb8c4b2c bump to v1.8.0 2023-11-09 12:55:10 +02:00
Craig Raw 2712555c72 followup 2 2023-11-09 12:50:39 +02:00
Craig Raw 72066395d6 followup 2023-11-09 12:31:18 +02:00
Craig Raw 7b3e5f37b9 improve vertical layout sizing on transaction tabs 2023-11-09 12:17:08 +02:00
Craig Raw 870a468584 add bisq segwit misderivation to mnemonic wallet discovery 2023-11-09 09:31:02 +02:00
Craig Raw 0340cba441 fix improvement to renaming wallet keystore labels for uniqueness 2023-11-09 09:10:54 +02:00
Craig Raw 91f845cbbf followup 2023-11-08 17:23:30 +02:00
Craig Raw d0f21eafd1 schedule regular check to indicate proxy status in sparrow terminal 2023-11-08 17:08:44 +02:00
Craig Raw 36c2181a7f remove keyboard shortcut+pageup/pagedown to switch tabs already added by default 2023-11-08 15:22:39 +02:00
Craig Raw 218e751333 display error message when attempting to mix from account 0 and it is not the master wallet 2023-11-08 12:29:54 +02:00
Craig Raw 995d2c5e4e temporarily disconnect from whirlpool if gap limit is increasing rapidly 2023-11-08 11:59:19 +02:00
Craig Raw 0d7ae74f0f fix whirlpool wallets with special characters in the passphrase 2023-11-08 09:27:25 +02:00
Craig Raw e6eea67c4b add search all open wallets functionality, include matches on transaction output addresses 2023-11-07 16:38:33 +02:00
Craig Raw ea3e0ca34a select all text in message sign signature field on mouse click 2023-11-07 15:16:06 +02:00
Craig Raw 3fedd8eb43 support opening multiple wallet and transaction files at once 2023-11-07 14:14:14 +02:00
Craig Raw 30408af719 add fee rate slider to private key sweep dialog 2023-11-07 14:02:17 +02:00
Craig Raw 910a400b18 fix computation of master fingerprint for satochip 2023-11-07 11:58:18 +02:00
Craig Raw f4ac18c3e1 increase gap limit where necessary to sign a psbt where global xpubs match 2023-11-02 12:38:51 +01:00
Craig Raw cb06e1aaf7 freeze and unfreeze utxos in sparrow terminal by pressing f on utxos table 2023-10-31 11:20:10 +01:00
Craig Raw d881e47ec9 indicate if proxy is enabled in sparrow terminal 2023-10-31 10:37:19 +01:00
Craig Raw dbafefb940 reload cormorant wallet if unloaded when polling bitcoind 2023-10-31 10:21:33 +01:00
Craig Raw ee6589991d add initial satochip card support 2023-10-31 09:56:31 +01:00
Craig Raw 24578dcf88 enlarge qr display dialog and increase default qr code density 2023-10-24 17:12:41 +02:00
Craig Raw ddae1a12d8 add keyboard shortcut ctrl+pageup/pagedown to switch tabs on windows/linux 2023-10-24 15:34:20 +02:00
Craig Raw 1c9b6c3eef add keyboard shortcut ctrl/cmd+alt+arrow to switch tabs 2023-10-24 15:06:21 +02:00
Craig Raw 158ecd4ab1 fix thread race issue when connecting to cormorant electrum server 2023-10-24 10:51:09 +02:00
Craig Raw b6bcdef712 minor fixes 2023-10-22 14:43:09 +02:00
Craig Raw 6eefd3f182 add duplicate payment address warning to transaction diagram 2023-10-12 12:37:44 +02:00
Craig Raw 9280504f70 add duplicate payment address warning to transaction diagram 2023-10-12 12:37:41 +02:00
Craig Raw a6a671f687 remove block hash from transaction tab fields, add to context menu for block height and timestamp 2023-10-11 13:08:42 +02:00
Craig Raw 4e3e8b7cc4 add figure caption to overview diagram on transaction tab to describe transaction 2023-10-11 11:44:40 +02:00
Craig Raw 2b8fc3900a followup 2023-10-06 13:50:44 +02:00
Craig Raw cff731dec7 ignore script type change warning when replacing wallet in settings 2023-10-05 12:37:27 +02:00
Craig Raw c9c0c35964 add krux as airgapped hww 2023-10-05 10:35:55 +02:00
Craig Raw afed5c65f5 add additional testnet public server qtornado.com 2023-10-05 08:53:12 +02:00
Craig Raw 0724c38582 add whirlpool postmix to the list of possible accounts that can be added to any legacy or segwit wallet 2023-09-29 14:37:58 +02:00
Craig Raw 31539a27ac improve fullscreen behaviour by setting dialog ownership to parent window 2023-09-29 12:02:14 +02:00
Craig Raw 201900aa0e add airgapped message signing via qr 2023-09-25 13:39:51 +02:00
Craig Raw 3dcbe34485 add scan button to qr display dialog to progress immediately to scanning 2023-09-21 14:07:58 +02:00
Craig Raw 31842cc0f2 update caravan and specter logos 2023-09-21 11:34:04 +02:00
Craig Raw f85349bd36 support hex border wallets grid pdf 2023-09-19 14:05:26 +02:00
Craig Raw a18c24e19f add /usr/lib64 as possible location for linux pcsc lib 2023-09-18 10:44:33 +02:00
Craig Raw e23c1b3872 force selection of a new configured server if currently configured server is deleted 2023-09-04 09:47:41 +02:00
Craig Raw 3c2ef43526 bump to v1.7.10 2023-08-31 13:36:36 +02:00
Craig Raw c81aae0c6a minor account renaming bug fixes 2023-08-31 10:54:16 +02:00
Craig Raw ff1a9e8a52 add or remove tooltip on account tab on renaming 2023-08-31 10:06:46 +02:00
Craig Raw 90bfa47046 add jade multisig wallet import 2023-08-31 09:34:15 +02:00
Craig Raw 6383b8b46f improve renaming wallet keystore labels for uniqueness 2023-08-30 15:21:43 +02:00
Craig Raw 76dc294748 support import and export of keystore labels in crypto-output qr codes 2023-08-30 14:22:18 +02:00
Craig Raw 0bc1dd96ed use port instead of unix socket for internal tor control to avoid bug where unix socket path is too long 2023-08-30 10:39:52 +02:00
Craig Raw 73d2d3cbbc replace hwi 2.3.1 binary with macos 10.13 compiled version 2023-08-30 09:55:08 +02:00
Craig Raw d5fdd6881c followup 2023-08-30 09:32:02 +02:00
Craig Raw b1bc25ba04 propagate transaction label changes to inputs, outputs and addresses where their existing labels were set in the same manner 2023-08-29 16:03:46 +02:00
Craig Raw 2c1204c247 add treetable config classes 2023-08-23 13:52:33 +02:00
Craig Raw 05a1fd8e8d refactor cointreetable column sorting, add default sorting if table was empty 2023-08-23 12:18:02 +02:00
Craig Raw 97f21394a7 add tooltip to account tab where label is truncated 2023-08-23 11:24:24 +02:00
Craig Raw c57a445046 add mempoolfullrbf config variable to enable rbf functionality on mempool transactions without checking sequence flags (default false) 2023-08-17 16:18:27 +02:00
Craig Raw fb981f1548 suggest clearing any existing keystores when script type is changed in settings 2023-08-17 15:35:33 +02:00
Craig Raw 407dde2703 upgrade to hwi 2.3.1 2023-08-17 12:33:03 +02:00
Craig Raw f175139fd3 add wallet summary dialog 2023-08-14 16:11:55 +02:00
Craig Raw bebd7eebe5 handle core bug where listwalletdir returns empty results 2023-08-14 10:02:35 +02:00
Craig Raw e01f6b42b1 add fulcrum.sethforprivacy.com public mainnet electrum server 2023-08-07 13:57:27 +02:00
Craig Raw bd1c6c076e avoid adding inputs when constructing a consolidation transaction replacement, allowing output to decrease 2023-08-07 12:54:29 +02:00
Craig Raw 46b1bd2fd2 improve address resolution error message 2023-08-07 12:20:07 +02:00
Craig Raw 2d7c5dcec7 terminal: check if theme is present to avoid exception on utxo history update 2023-08-07 12:00:58 +02:00
Craig Raw 93bcf6cef9 fix single character multisig threshold parsing issue 2023-07-23 13:13:30 +02:00
Craig Raw 4b6a03ef56 for zbar scans, return scanned characters as raw bytes 2023-07-23 12:48:04 +02:00
Craig Raw 7ff4230e13 bump to v1.7.9 2023-07-18 16:22:43 +02:00
Craig Raw 7d7967ec00 improve handling of invalid bip322 signatures 2023-07-18 12:25:39 +02:00
Craig Raw b0883f034b change type and name of enable zbar config variable 2023-07-18 11:49:25 +02:00
Craig Raw f88628c469 terminal: set scrollbar to top if scrolled below new utxo table row count 2023-07-18 10:58:11 +02:00
Craig Raw ac7a964edf make alert dialogs resizable 2023-07-18 10:37:27 +02:00
Craig Raw 7bb22419df add rename wallet menu command 2023-07-18 10:09:37 +02:00
Craig Raw c443bc78d3 add taproot script type to connected wallet import options 2023-07-17 11:57:34 +02:00
Craig Raw a5519537c5 add tooltip to save final transaction button to suggest connecting to a server in order to broadcast 2023-07-17 11:49:17 +02:00
Craig Raw ef67d1f33b show warning dialog on submit bug report to redirect users to support where appropriate 2023-07-17 11:44:16 +02:00
Craig Raw 31bd64f821 show warning in preferences if currencies could not be retrieved from exchange rate source 2023-07-17 11:17:16 +02:00
Craig Raw 4c408ac7b1 make send to many dialog non-modal, menu command brings an existing dialog to foreground 2023-07-17 10:52:01 +02:00
Craig Raw 30a9c1208a add config variable to disable zbar scanning 2023-07-17 08:48:34 +02:00
Craig Raw 96fd824a3e add zbar qr reader for all qr scans 2023-07-14 15:04:27 +02:00
Craig Raw 0a469a380b when constructing rbf and cpfp transactions, add any additional utxos by output group if effective fee is sufficient 2023-07-11 11:25:29 +02:00
Craig Raw 5e3f31de30 followup 2023-07-11 10:07:00 +02:00
Craig Raw af9fb8694e set initial fee for rbf tx to satisfy minimum relay requirements 2023-07-11 09:50:19 +02:00
Craig Raw ebfdfc0c9f use txo filters for all wallet transaction output filtering, fixing overselection of inputs during rbf 2023-07-11 09:08:40 +02:00
Craig Raw c9d6bb350d rearrange recent files list when tabs are moved left or right 2023-07-05 09:59:22 +02:00
Craig Raw f980516462 various minor improvements 2023-07-05 09:48:48 +02:00
Craig Raw 795892f7c9 show error for bip322 multisig signatures 2023-07-04 09:22:15 +02:00
Craig Raw 9576581d89 add bip322 message signing for singlesig addresses including p2tr 2023-07-04 08:51:38 +02:00
Craig Raw 296223130e cormorant: optimize memory used for calculating fee rate histogram 2023-06-30 09:00:37 +02:00
Craig Raw 87e2da0e01 followup 2023-06-26 12:25:55 +02:00
Craig Raw 9156ea1114 preserve payment labels when using rbf on multiple payment transactions 2023-06-26 12:15:58 +02:00
Craig Raw b8fc2fd59e only keep older mempool histogram entries at ten minute intervals 2023-06-26 10:45:16 +02:00
Craig Raw ef9723ed44 add additional utxos to cpfp transaction if output value is below dust threshold 2023-06-23 13:39:38 +02:00
Craig Raw 5105b503ea set transaction label to comma separated list of payment labels when multiple payments are made 2023-06-23 10:30:51 +02:00
Craig Raw 9bcb34e7d1 retain utxo frozen status on wallet refresh 2023-06-23 09:55:24 +02:00
Craig Raw 185a17edce bump to v1.7.8 2023-06-22 12:56:29 +02:00
Craig Raw f8fa7f4cf2 add separate button to backup tapsigner without changing pin 2023-06-22 10:41:59 +02:00
Craig Raw bcbb531414 fix ui button update when stopping mixing on terminal 2023-06-22 09:10:52 +02:00
Craig Raw 2deab05c45 remove path from non-batched electrum server requests 2023-06-22 08:21:57 +02:00
Craig Raw c7923300c6 update kmp-tor to v1.4.3, fix storage tests 2023-06-22 07:54:48 +02:00
Craig Raw c4651025be format fee rates in broadcast errors using unit format 2023-06-21 16:42:57 +02:00
Craig Raw 2897f88c8b fix unit format switching on send tab 2023-06-21 15:47:21 +02:00
Craig Raw ee20a6980b fix incorrect hours check for 24h mempool fee rates toggle 2023-06-21 11:40:04 +02:00
Craig Raw 721c446fa8 update seedsigner help text 2023-06-21 10:36:34 +02:00
Craig Raw 7c43ee7208 add context menu options to date column in utxos table to freeze utxo and view transaction 2023-06-21 09:33:42 +02:00
Craig Raw c6ea37e081 add 24h mempool fees chart toggle 2023-06-21 08:33:05 +02:00
Craig Raw 171bf24133 optimize fetching mempool entries for fee histogram when connected to bitcoin core, fix and improve mempool fee rates chart 2023-06-20 16:15:19 +02:00
Craig Raw 3242f00812 improve performance on large wallets with high address reuse 2023-06-15 12:19:09 +02:00
Craig Raw 15500b6535 highlight csv download buttons 2023-06-13 14:13:44 +02:00
Craig Raw 700c880b92 improve error message on broadcasting a tx with fee rate below the purge rate for the connected mempool 2023-06-11 10:50:19 +02:00
Craig Raw 7f2c07c918 set all witness utxos on psbt inputs before attempting to sign sweep tx 2023-06-08 11:07:32 +02:00
Craig Raw 0745d21761 fix export to specter desktop without any wallet history 2023-06-08 08:58:43 +02:00
Craig Raw 50aa9b4dcb ensure canonical ordering of key expressions in multisig descriptor qr 2023-06-07 10:21:29 +02:00
Craig Raw fe50cb845e log batch errors getting fee estimates from server 2023-06-05 11:49:10 +02:00
Craig Raw f4b9807285 widen mnemonic grid dialog to accommodate word numbers 2023-06-04 10:02:55 +02:00
Craig Raw f534beb624 add additional rbf tx inputs if needed as required fee is increased 2023-06-01 15:31:15 +02:00
Craig Raw 719cfaa906 support airgapped keystore import of a tapsigner with a custom derivation path 2023-05-31 15:50:06 +02:00
Craig Raw 98d9a6882b look for supported cards across all connected card terminals 2023-05-31 14:25:45 +02:00
Craig Raw dbbeaf67b6 support spendable property on utxos in bip329 wallet label imports and exports 2023-05-31 10:42:32 +02:00
Craig Raw 2a542bb8b9 avoid multiple selection on border wallets grid, display word number, clarify recovery phrase purpose on pdf 2023-05-30 14:01:45 +02:00
Craig Raw 29b630f6bf update to hummingbird v1.6.7 to support pair path components and unique part progress indicator 2023-05-29 17:20:43 +02:00
Craig Raw 3aa00076c6 trim leading and trailing whitespace from pay to address field 2023-05-25 16:15:38 +02:00
Craig Raw 3cf99961d3 improve error message when broadcasting an rbf transaction with insufficient fee, indicate minimum required fee if available 2023-05-25 14:48:32 +02:00
Craig Raw 742727d6f2 add ctrl/cmd+b keyboard shortcut for quickly switching from btc to satoshis and back 2023-05-25 13:22:44 +02:00
Craig Raw 5d99eee89a invert cropped frame and scan for inverted qrs 2023-05-25 12:29:43 +02:00
Craig Raw b52be27a99 support scanning text qr containing seed words 2023-05-15 15:49:39 -05:00
Craig Raw e68f177e4a bump to v1.7.7 2023-04-14 18:31:10 -06:00
Craig Raw e0a6626650 add further guidance when regenerating a border wallets grid 2023-04-14 16:41:47 -06:00
Craig Raw 1d8888bb14 fix button placement on border wallets dialog in windows 2023-04-14 15:59:24 -06:00
Craig Raw d0958b7936 clear border wallets selected cells on grid initialization 2023-04-14 13:26:29 -06:00
Craig Raw d9d316a627 bump to v1.7.6 2023-04-14 10:25:08 -06:00
Craig Raw 0270910b74 update specter desktop wallet import 2023-04-14 08:29:24 -06:00
Craig Raw 432e0642ca cormorant: improve error message when core wallet support is disabled 2023-04-14 07:50:06 -06:00
Craig Raw c7ab8e4601 support broadcasting via mempool.space when using signet 2023-04-14 07:41:00 -06:00
Craig Raw 1a46f8a643 preserve order of cell selection in border wallets grid 2023-04-14 06:36:34 -06:00
Craig Raw 04145bde74 add border wallets number grid support 2023-04-06 14:57:56 +02:00
Craig Raw 483e4c8f38 always show tapsigner in airgapped import options 2023-04-05 08:24:18 +02:00
Craig Raw fe4468d49d bump to v1.7.5 2023-04-05 08:10:36 +02:00
Craig Raw a4e9ef989d darker dark theme and other styling improvements 2023-04-04 12:33:12 +02:00
Craig Raw 4ed8550f1d fix dark themed spreadsheets 2023-04-04 09:22:49 +02:00
Craig Raw 4bec71e7c4 read name from ur:crypto-hdkey and set keystore label if present 2023-03-29 16:00:32 +02:00
Craig Raw 961fd94dd6 allow stowaway counterparty to spend postmix utxos but receive to master wallet 2023-03-29 15:17:19 +02:00
Craig Raw 7915bbfa47 save border wallet pdf when generating a new recovery phrase 2023-03-29 12:00:04 +02:00
Craig Raw 49e70e8e9b fix accidentally making keystore fields editable on loading of single account non-watchonly wallets 2023-03-29 09:22:45 +02:00
Craig Raw 6063b02113 generate border wallets grid from seed words 2023-03-29 08:21:15 +02:00
Craig Raw faa5a11c94 update tor to 0.4.7.13 using kmp-tor library 2023-03-28 14:38:20 +02:00
Craig Raw acab50cdcd add diynodes.com public mainnet electrum server 2023-03-28 09:15:38 +02:00
Craig Raw 4d7d897e06 add port of deterministic prng for border wallets word shuffle 2023-03-28 08:37:20 +02:00
Craig Raw af532e7fc9 import seed via border wallets grid pattern 2023-03-27 11:00:32 +02:00
Craig Raw fd2b383dbc autosuggest possible words for the last word in a bip39 seed 2023-03-22 15:50:12 +02:00
Craig Raw 98b33e184e use whirlpool premix priority slider to change mixfeetarget, display warning if chosen fee rate is much lower than normal priority 2023-03-21 09:34:19 +02:00
Craig Raw 3bc7c7473a fix error initializing whirlpool on new wallet without a passphrase 2023-03-21 08:34:06 +02:00
Craig Raw 4f6981b869 change wallet gap limit and subscribe to new addresses if an address beyond gap limit range is requested 2023-03-15 08:44:53 +02:00
Craig Raw 258d46a253 support saving tapsigner backup as binary file 2023-03-09 13:40:57 +02:00
Craig Raw 40a3eb5d4f followup, minor tidying 2023-03-09 11:01:25 +02:00
craigrawandGitHub 35965235f3 Merge pull request #860 from wazint/master
Add context menu to copy amount values from amount cells
2023-03-09 10:55:36 +02:00
Craig Raw 368b24ea3b cormorant: handle empty (0 block only) chains 2023-03-09 10:09:52 +02:00
Craig Raw 107b5ba36c show psbt qrs without non witness utxo entries for segwit signing wallets 2023-03-07 13:17:33 +02:00
Craig Raw 84978a3d5d use different addresses when sending batched payments to the same paynym 2023-03-06 12:30:26 +02:00
Craig Raw dd3b980c36 strip path from server url when determining host and port 2023-03-06 10:40:46 +02:00
wazint b9a553abf2 don't initialise a ContextMenu for each cell update 2023-03-04 20:30:47 +02:00
Craig Raw 48b3dbc353 bump to v1.7.4 2023-03-01 10:43:36 +02:00
Craig Raw fb40d991bb replace default textinput dialog with custom textfield dialog 2023-03-01 08:14:25 +02:00
Craig Raw 5fe6a7196a use uppercase to encode pdf output descriptor qr 2023-02-28 16:52:42 +02:00
Craig Raw f06b859c82 extend request timeout for paynym api 2023-02-28 14:37:46 +02:00
Craig Raw 80dd59928e upgrade jlink plugin to support java 19 2023-02-28 14:21:57 +02:00
Craig Raw f22f76464a add option ot disable block explorer 2023-02-28 13:59:45 +02:00
Craig Raw dfe1f16495 configure a block explorer url, and open a txid in the configured block explorer 2023-02-28 13:19:24 +02:00
Craig Raw 90a9030ecb allow crypto-output qr scanning from wallet import dialog 2023-02-27 14:03:12 +02:00
Craig Raw 4ab33a373c reduce log level for no card reader errors 2023-02-27 12:36:41 +02:00
Craig Raw 10e751d6e1 upgrade to hwi 2.2.1 2023-02-27 12:18:50 +02:00
Craig Raw 5f40669af7 request confirmation before enabling use of a bip39 passphrase 2023-02-27 09:11:06 +02:00
wazint 97b4ed48db add context menu to copy amount values from amount cells 2023-02-24 16:55:40 +02:00
Craig Raw 9fc096569a add minfeerate parameter to bip78 payjoin urls 2023-02-23 12:53:12 +02:00
Craig Raw 41636f7152 support encrypted bip129 wallet imports 2023-02-23 12:17:41 +02:00
Craig Raw fc5d48de6f bip129 round 2 support (wallet import and export) 2023-02-23 12:02:06 +02:00
Craig Raw 2a7f14a4ed bip129 round 1 support with optional signing of bsms keystore exports 2023-02-22 10:22:04 +02:00
Craig Raw 1cb6778502 bump required java version to 18 2023-02-20 11:56:34 +02:00
Craig Raw 7f254e763d fix keystore encryption issue when changing the password on a wallet with freshly added accounts 2023-02-17 09:10:40 +02:00
Craig Raw e0ff42b6a4 terminal: add lock menu item to all wallets with a password 2023-02-17 08:37:10 +02:00
craigrawandGitHub 88fc8f5017 Merge pull request #843 from secondl1ght/master
add StartupWMClass to linux .desktop file
2023-02-15 08:00:36 +02:00
secondl1ghtandGitHub d7072928de add StartupWMClass to linux .desktop file 2023-02-14 21:22:41 -07:00
Craig Raw 0cc9ddba05 read and throw hwi error stream if stdout empty 2023-02-14 09:57:23 +02:00
Craig Raw e3799cd0a8 fix error receiving a stowaway to postmix by reverting to master wallet 2023-02-13 18:27:21 +02:00
Craig Raw ea6b30326e fix taproot signature hash for single | anyonecanpay 2023-02-13 17:35:17 +02:00
Craig Raw 38768885e2 show hwi signature verification errors, display strings encoded into scripts 2023-02-13 16:36:33 +02:00
Craig Raw c360177c31 use default ports for bitcoin core if absent, fix ux on changing port for an aliased server 2023-02-12 13:06:21 +02:00
Craig Raw e88ea0bac1 improve ux of bip39 wallet discovery on bitcoin core 2023-02-12 11:48:46 +02:00
Craig Raw a66b36c59c cormorant: switch bitcoind client from named to array parameters to support btc-rpc-proxy 2023-02-11 14:28:10 +02:00
Craig Raw fb3b674b65 recompile secp256k1 on osx 10.13.6 2023-02-10 09:23:02 +02:00
Craig Raw eff0e201f3 cormorant: only use proxy when connecting to onion addresses 2023-02-10 09:12:09 +02:00
Craig Raw 58d10cbba4 v1.7.3 2023-02-09 18:30:28 +02:00
Craig Raw 29bce8a9bc revert to ubuntu 20.04 runner 2023-02-09 17:34:19 +02:00
Craig Raw 67dcf69a78 v1.7.2 2023-02-09 13:18:30 +02:00
Craig Raw 7ad8a04bda improve legacy core wallet error messages 2023-02-09 13:14:03 +02:00
Craig Raw 24e75603c6 update categories for linux desktop installation 2023-02-09 10:53:45 +02:00
Craig Raw 545342dfb4 disable server toggle when no server is configured (url host is empty) 2023-02-09 10:29:19 +02:00
Craig Raw b15d6308bd write and parse both multipath and single descriptors in wallet output descriptor export 2023-02-09 09:06:30 +02:00
Craig Raw ff0c381437 cormorant: find cookie dir for non-mainnet networks 2023-02-08 14:10:20 +02:00
Craig Raw 555260e954 implement bip329 for importing and exporting wallet labels 2023-02-08 08:03:06 +02:00
Craig Raw 8d584d1c48 followup for aarch64 2023-02-07 09:13:32 +02:00
Craig Raw 967cf0cdfa try to locate pcsc library on linux before searching for card terminals 2023-02-06 16:44:34 +02:00
Craig Raw 41ba8455a0 cormorant: avoid importing wallets when testing connection, only show prune warning once per connection 2023-02-06 13:25:35 +02:00
Craig Raw d84f3bf887 add config property autoSwitchProxy to disable automatic proxy switching on failure, and improve tor connection failure message 2023-02-06 11:38:19 +02:00
Craig Raw 153815d9e3 indicate in ssl handshake warning that a certificate renewal may be the cause 2023-02-06 11:01:42 +02:00
Craig Raw 0250579445 tapsigner: change card backup to base64 2023-02-06 10:56:53 +02:00
Craig Raw 7590d786b5 fix copy address output script bytes to return entire scriptpubkey 2023-02-03 13:57:29 +02:00
Craig Raw 55809b7dc3 decrypt keystore before requesting passphrase to show masterfingerprint 2023-02-03 08:20:09 +02:00
Craig Raw 06026b0a09 further improvements on wallet importing wrt pruned nodes 2023-02-01 14:30:24 +02:00
Craig Raw 2cd64aa650 improve handling of scan dates earlier than core pruned date 2023-02-01 13:48:20 +02:00
Craig Raw 0b980f6ab5 satscard: retrieve private keys for previously used slots 2023-02-01 11:37:36 +02:00
Craig Raw 73dcef9fd1 followup: add or remove card option from pay to dropdown as reader becomes available 2023-02-01 10:07:59 +02:00
Craig Raw 4e3491ec64 tapsigner and satscard initialization fixes, satscard address and private key retrieval, core address scanning support 2023-02-01 09:39:49 +02:00
Craig Raw 176e440195 unseal satscard functionality added to sweep private key dialog 2023-01-31 09:30:53 +02:00
Craig Raw 300545b289 refactor cardapi to generic service 2023-01-30 14:47:00 +02:00
Craig Raw 057a9efb1f cormorant: fix initialisation of sent txes without txindex 2023-01-30 14:46:12 +02:00
Craig Raw 9edeff9aab cormorant: set wallet to load on bitcoind startup, check if loaded first 2023-01-30 12:28:07 +02:00
Craig Raw f938506a3f add tapsigner message signing support 2023-01-30 09:41:12 +02:00
Craig Raw 4fb8c5a61b add card scan to hwi enumeration and refactor device pane 2023-01-27 13:58:38 +02:00
Craig Raw 7a99c4a11a add tapsigner signing support and refactor card api 2023-01-27 10:39:29 +02:00
Craig Raw 6c13504644 implement card initialization functionality 2023-01-26 15:47:33 +02:00
Craig Raw 3ddf4ed4b2 add functionality for tapsigner backup and pin change 2023-01-26 13:00:25 +02:00
Craig Raw 6b59ff60ad initialize and import tapsigner as keystore 2023-01-25 14:19:22 +02:00
Craig Raw 7c64d689fd cormorant: threading and scan date initialization improvements 2023-01-20 12:56:25 +02:00
Craig Raw 4ad9cdedb6 add merge function for wallet transaction entry edge case 2023-01-19 14:12:35 +02:00
Craig Raw 276cb8aecb cormorant: support transaction.get without txindex, use step function to add bip47 addresses 2023-01-19 13:52:47 +02:00
Craig Raw e7ed82699c add jade multisig export to wallet export dialog 2023-01-18 13:25:39 +02:00
Craig Raw 68cd3673af upgrade to hwi 2.2.0, add support for entering empty passphrases to trezor one 2023-01-18 11:13:13 +02:00
Craig Raw 5f96570c07 request treetable focus after editing a label cell 2023-01-16 14:46:24 +02:00
Craig Raw 5147ee8aee prefer loading transaction inputs from existing wallet transactions 2023-01-16 14:13:15 +02:00
Craig Raw 3cc2981b72 followup 2023-01-11 14:14:31 +02:00
Craig Raw 8038298485 show lifehash for master fingerprint in settings and passphrase dialog 2023-01-11 14:01:41 +02:00
Craig Raw d1a1bd5751 fix persistence of renaming and deleting newly created wallet accounts 2023-01-09 09:57:35 +02:00
Craig Raw 63b7aef91e upgrade gradle to 7.6 2023-01-07 08:23:44 +02:00
Craig Raw 8a51a47156 upgrade gradle to 7.6 2023-01-06 10:49:26 +02:00
Craig Raw 827efe071e upgrade junit 2023-01-05 12:17:49 +02:00
Craig Raw 56784b684a allow expired certificates for electrum servers so long as they have been previously used or explicitly approved 2022-12-16 12:06:05 +02:00
Craig Raw 1fa52f043c add fee column to transactions csv for outgoing (spending) transactions 2022-12-15 17:04:01 +02:00
Craig Raw ce44cfe877 export 8 decimal places when exporting a csv in btc units 2022-12-15 16:37:33 +02:00
Craig Raw 8ba0a9f360 cormorant: increase descriptor wallet gap limit for postmix receive chain 2022-12-15 16:00:56 +02:00
Craig Raw 41dabac75b cormorant: syncing and pruning improvements 2022-12-15 14:03:56 +02:00
Craig Raw 064708f088 avoid copying wallet history unnecessarily on wallet load 2022-12-15 08:29:25 +02:00
Craig Raw 66dc394215 cormorant: send first scan event immediately 2022-12-14 16:29:16 +02:00
Craig Raw 5ca60699ef cormorant: improve scanning behaviour 2022-12-14 14:55:00 +02:00
Craig Raw 61d9ad1875 cormorant: rbf handling and related fixes 2022-12-12 12:55:19 +02:00
Craig Raw af6bbebac4 cormorant: add batching support 2022-12-12 11:33:51 +02:00
Craig Raw 6f4fc4f2ca avoid triggering all history changed event on unconfident script hash status calculations 2022-12-12 10:49:29 +02:00
Craig Raw 00f5001385 cormorant: fix scan date of nested wallet import 2022-12-12 10:09:22 +02:00
Craig Raw 3f3cdca94f refactor out unnecessary parameter 2022-12-12 09:14:17 +02:00
Craig Raw 08cf01a5c6 add cormorant server to support bitcoin core descriptor wallets 2022-12-08 08:42:40 +02:00
Craig Raw df7f40dbc9 followup 2022-12-07 11:00:16 +02:00
Craig Raw 12c1725260 fix edge case when loading wallets with matching tx inputs and outputs 2022-12-06 11:22:35 +02:00
Craig Raw aa8380eb03 add https protocol for bitcoin core connections over tls 2022-12-05 11:57:25 +02:00
Craig Raw 0e26f8fce1 add note on disabling derivation path validation to keystore help tooltip 2022-12-03 07:41:56 +02:00
Craig Raw 8de14dcbce improve handling of certain electrum server errors 2022-12-02 17:16:34 +02:00
Craig Raw 6871810c7c improve display of json rpc error exceptions 2022-12-01 09:39:47 +02:00
Craig Raw 6ac294920e improve encapsulation and binding lifecycle of cell confirmation listeners 2022-11-30 11:12:01 +02:00
Craig Raw 4b32eb397e add seedtool svg icons 2022-11-30 08:31:06 +02:00
Craig Raw b25297e8b9 fix sparrow export file extension to be always mv.db 2022-11-28 12:59:30 +02:00
Craig Raw ff90a2c3e6 add block height to terminal connected label 2022-11-28 09:43:08 +02:00
Craig Raw 3cbe8d1537 set initial focus to done button on terminal server test dialog 2022-11-21 16:21:30 +02:00
Craig Raw 9293b622a3 allow message sign/verify from nested segwit wallets 2022-11-21 16:09:59 +02:00
Craig Raw 6337e1cf7d update dependencies for guava, simple-json-rpc and dependants 2022-11-21 12:08:27 +02:00
craigrawandGitHub 3ff3fb29b0 Merge pull request #753 from lukechilds/patch-1
Update bitcoin.lukechilds.co to bitcoin.lu.ke
2022-11-21 09:09:37 +02:00
craigrawandGitHub 149d297193 Merge pull request #752 from BitcoinQnA/master
Updated Passport Single/Multisig Import and Export Instructions
2022-11-21 09:05:34 +02:00
Craig Raw 47f7b8870c fix cancel of create wallet in terminal 2022-11-21 08:51:09 +02:00
Luke ChildsandGitHub 7ce7d37da7 Update bitcoin.lukechilds.co to bitcoin.lu.ke 2022-11-19 03:30:36 +07:00
BitcoinQnAandGitHub b422c754d6 Update Passport Single-sig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:52:58 +00:00
BitcoinQnAandGitHub be6e9019dc Update Passport Multisig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:49:26 +00:00
Craig Raw 011bb86b5f fix negative space 2022-11-17 15:45:24 +02:00
Craig Raw 8e1163d3db add seedsigner svg icons 2022-11-17 15:37:04 +02:00
Craig Raw 83c8b1c8e6 use svg icons where possible 2022-11-17 14:06:27 +02:00
Craig Raw d44aecea90 v1.7.1 2022-11-17 10:23:26 +02:00
Craig Raw c9288ab25b change qr code density for ur encoding via qr dialog button 2022-11-17 10:19:04 +02:00
Craig Raw e39a2cb944 fix isconnecting state change 2022-11-17 08:08:50 +02:00
Craig Raw fb25edb51c avoid logging a socket closed error when the connection has been shutdown 2022-11-16 13:05:48 +02:00
Craig Raw 06ff0498d4 revert 29cd321 and add special case for mixed seed/watch only multisig wallets 2022-11-16 11:16:14 +02:00
Craig Raw 63b27e7054 hide spend and freeze context menu items on address cell in utxo table 2022-11-16 10:13:55 +02:00
Craig Raw 0260a12663 close connecting sockets and interrupt read thread on shutdown 2022-11-16 08:16:56 +02:00
Craig Raw a05fcba6d9 add inverted icons for dark theme 2022-11-14 12:45:13 +02:00
Craig Raw 5be5363f25 optimization followup 2022-11-14 11:40:00 +02:00
Craig Raw cc961b4eeb all walletconfig for wallet scope configuration variables 2022-11-14 11:00:26 +02:00
Craig Raw 7e7795196c bring window to front when restored after being minimized to tray 2022-11-09 12:37:55 +02:00
Craig Raw fd0fe1110d improve terminal resizing behaviour 2022-11-09 12:15:12 +02:00
Craig Raw ea64fa0f85 terminal - show receive address as qr code 2022-11-09 11:40:34 +02:00
Craig Raw 2972f1a4d7 fix export from settings tabs for new account by ensuring bidirectional links are restored on save 2022-11-08 15:51:59 +02:00
Craig Raw 6990b398c2 always use db format for sparrow exporter 2022-11-08 15:50:27 +02:00
Craig Raw a25b53bd44 add error message when connecting to bitcoin core with a taproot wallet 2022-11-08 10:36:56 +02:00
Craig Raw 871c503bc9 terminal - add mix selected functionality to broadcast premix transactions 2022-11-08 10:09:25 +02:00
Craig Raw b7992ae9e1 improve .deb control file and avoid dependence on xdg-utils when building sparrow-server debs 2022-11-07 10:08:50 +02:00
Craig Raw 0a8eb2fbb7 avoid triggering close wallet events when reordering tabs 2022-11-07 09:05:08 +02:00
Craig Raw 7863fb7632 delay wallet file deletion to allow for database compaction and show error on failure 2022-11-07 08:29:29 +02:00
Craig Raw 6481d83b0c avoid using locale for unit formatting 2022-11-02 11:40:39 +02:00
Craig Raw 402d9b14ec fix typos and clarify submodule update description 2022-11-02 08:41:50 +02:00
craigrawandGitHub feeb4f0ac0 Merge pull request #730 from RequestPrivacy/master
Add git pull & git submodule update --checkout
2022-11-02 08:35:59 +02:00
RequestPrivacyandGitHub 143eb3213e Formating & Addition of cd Command 2022-11-01 17:42:17 +01:00
RequestPrivacyandGitHub cd96fc1daa Add git pull & git submodule update --checkout 2022-11-01 14:04:22 +01:00
Craig Raw 73d9cd2e68 disable assistive technologies in windows to avoid runtime crash 2022-10-31 17:14:58 +02:00
Craig Raw 3faf817148 consider ip range 100.64.0.0/10 as local network addresses 2022-10-31 08:41:22 +02:00
Craig Raw 96c88b7472 fix npe in terminal for exchange rate updates without a btc rate 2022-10-29 08:33:55 +02:00
Craig Raw e2795c7ef3 fix potential npe selecting server in alias dialog 2022-10-28 09:41:31 +02:00
454 changed files with 18392 additions and 1856 deletions
+4 -1
View File
@@ -2,12 +2,15 @@ name: Package
on: workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
os: [windows-2022, ubuntu-20.04, macos-12]
steps:
- uses: actions/checkout@v3
with:
+2 -2
View File
@@ -16,7 +16,7 @@ or for those without SSH credentials:
`git clone --recursive https://github.com/sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 17 or higher to be installed.
In order to build, Sparrow requires Java 18 or higher to be installed.
The release binaries are built with [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10).
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
@@ -44,7 +44,7 @@ If you prefer to run Sparrow directly from source, it can be launched from withi
`./sparrow`
Java 17 or higher must be installed.
Java 18 or higher must be installed.
## Configuration
+176 -53
View File
@@ -4,10 +4,10 @@ plugins {
id 'application'
id 'org-openjfx-javafxplugin'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.25.0'
id 'org.beryx.jlink' version '2.26.0'
}
def sparrowVersion = '1.7.0'
def sparrowVersion = '1.8.1'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -23,6 +23,19 @@ if(System.getProperty("os.arch") == "aarch64") {
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
def vTor = '4.7.13-4'
def vKmpTor = '1.4.3'
def kmpOs = osName
if(os.macOsX) {
kmpOs = "macos"
} else if(os.windows) {
kmpOs = "mingw"
}
def kmpArch = "x64"
if(System.getProperty("os.arch") == "aarch64") {
kmpArch = "arm64"
}
group "com.sparrowwallet"
version "${sparrowVersion}"
@@ -54,7 +67,7 @@ dependencies {
exclude group: 'org.hamcrest'
exclude group: 'junit'
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.guava:guava:31.1-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3')
@@ -69,14 +82,14 @@ dependencies {
exclude group: 'com.beust', module: 'jcommander'
}
implementation('com.beust:jcommander:1.81')
implementation('com.github.arteam:simple-json-rpc-core:1.0')
implementation('com.github.arteam:simple-json-rpc-client:1.0') {
implementation('com.github.arteam:simple-json-rpc-core:1.3')
implementation('com.github.arteam:simple-json-rpc-client:1.3') {
exclude group: 'com.github.arteam', module: 'simple-json-rpc-core'
}
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('com.sparrowwallet:hummingbird:1.6.7')
implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
@@ -84,10 +97,15 @@ dependencies {
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}${targetName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
implementation "io.matthewnelson.kotlin-components:kmp-tor:${vTor}-${vKmpTor}"
if(kmpOs == "linux" && kmpArch == "arm64") {
implementation("com.sparrowwallet.kmp-tor-binary:kmp-tor-binary-${kmpOs}${kmpArch}-jvm:${vTor}")
} else {
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-${kmpOs}${kmpArch}:${vTor}")
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-extract:${vTor}")
implementation("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager:${vKmpTor}")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.1')
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
@@ -103,14 +121,17 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.33')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.39')
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.github.librepdf:openpdf:1.3.30')
implementation('com.googlecode.lanterna:lanterna:3.1.1')
testImplementation('junit:junit:4.12')
implementation('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2')
implementation('com.sparrowwallet:toucan:0.9.0')
testImplementation('junit:junit:4.13.1')
}
application {
@@ -157,8 +178,11 @@ run {
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson"]
"--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
@@ -204,9 +228,11 @@ jlink {
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop",
"--add-reads=com.sparrowwallet.merged.module=java.sql",
"--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow",
@@ -214,8 +240,12 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.databind",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor"]
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
}
if(os.macOsX) {
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
@@ -237,7 +267,12 @@ jlink {
installerType = "exe"
}
if(os.linux) {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow', '--linux-rpm-license-type', 'ASL 2.0']
if(headless) {
installerOptions = ['--license-file', 'LICENSE']
} else {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow']
}
installerOptions += ['--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
}
if(os.macOsX) {
@@ -272,7 +307,7 @@ task packageTarDistribution(type: Tar) {
}
extraJavaModuleInfo {
module('jackson-core-2.10.1.jar', 'com.fasterxml.jackson.core', '2.10.1') {
module('jackson-core-2.13.2.jar', 'com.fasterxml.jackson.core', '2.13.2') {
exports('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.core.async')
exports('com.fasterxml.jackson.core.base')
@@ -287,11 +322,11 @@ extraJavaModuleInfo {
exports('com.fasterxml.jackson.core.util')
uses('com.fasterxml.jackson.core.ObjectCodec')
}
module('jackson-annotations-2.10.1.jar', 'com.fasterxml.jackson.annotation', '2.10.1') {
module('jackson-annotations-2.13.2.jar', 'com.fasterxml.jackson.annotation', '2.13.2') {
requires('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.annotation')
}
module('jackson-databind-2.10.1.jar', 'com.fasterxml.jackson.databind', '2.10.1') {
module('jackson-databind-2.13.2.jar', 'com.fasterxml.jackson.databind', '2.13.2') {
requires('java.desktop')
requires('java.logging')
requires('com.fasterxml.jackson.annotation')
@@ -325,7 +360,7 @@ extraJavaModuleInfo {
exports('tornadofx.control')
requires('javafx.controls')
}
module('simple-json-rpc-core-1.0.jar', 'simple.json.rpc.core', '1.0') {
module('simple-json-rpc-core-1.3.jar', 'simple.json.rpc.core', '1.3') {
exports('com.github.arteam.simplejsonrpc.core.annotation')
exports('com.github.arteam.simplejsonrpc.core.domain')
requires('com.fasterxml.jackson.core')
@@ -333,7 +368,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind')
requires('org.jetbrains.annotations')
}
module('simple-json-rpc-client-1.0.jar', 'simple.json.rpc.client', '1.0') {
module('simple-json-rpc-client-1.3.jar', 'simple.json.rpc.client', '1.3') {
exports('com.github.arteam.simplejsonrpc.client')
exports('com.github.arteam.simplejsonrpc.client.builder')
exports('com.github.arteam.simplejsonrpc.client.exception')
@@ -341,7 +376,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind')
requires('simple.json.rpc.core')
}
module('simple-json-rpc-server-1.0.jar', 'simple.json.rpc.server', '1.0') {
module('simple-json-rpc-server-1.3.jar', 'simple.json.rpc.server', '1.3') {
exports('com.github.arteam.simplejsonrpc.server')
requires('simple.json.rpc.core')
requires('com.google.common')
@@ -388,16 +423,11 @@ extraJavaModuleInfo {
requires('java.xml')
requires('java.logging')
}
module('kotlin-logging-1.5.4.jar', 'io.github.microutils.kotlin.logging', '1.5.4') {
exports('mu')
requires('kotlin.stdlib')
requires('org.slf4j')
}
module('failureaccess-1.0.1.jar', 'failureaccess', '1.0.1') {
exports('com.google.common.util.concurrent.internal')
}
module('listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar', 'com.google.guava.listenablefuture', '9999.0-empty-to-avoid-conflict-with-guava')
module('guava-28.2-jre.jar', 'com.google.common', '28.2-jre') {
module('guava-31.1-jre.jar', 'com.google.common', '31.1-jre') {
exports('com.google.common.eventbus')
exports('com.google.common.net')
exports('com.google.common.base')
@@ -468,13 +498,6 @@ extraJavaModuleInfo {
requires('javafx.graphics')
}
module('jai-imageio-core-1.4.0.jar', 'com.github.jai.imageio.jai.imageio.core', '1.4.0')
module('kotlin-stdlib-jdk8-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk8', '1.5.20')
module('kotlin-stdlib-jdk7-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk7', '1.5.20')
module('kotlin-stdlib-1.5.20.jar', 'kotlin.stdlib', '1.5.20') {
exports('kotlin')
exports('kotlin.jvm')
exports('kotlin.collections')
}
module('hummingbird-1.6.3.jar', 'com.sparrowwallet.hummingbird', '1.6.3') {
exports('com.sparrowwallet.hummingbird')
exports('com.sparrowwallet.hummingbird.registry')
@@ -482,8 +505,10 @@ extraJavaModuleInfo {
}
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
}
module('nightjar-0.2.33.jar', 'com.sparrowwallet.nightjar', '0.2.33') {
module('nightjar-0.2.39.jar', 'com.sparrowwallet.nightjar', '0.2.39') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -571,29 +596,127 @@ extraJavaModuleInfo {
module('jcip-annotations-1.0.jar', 'net.jcip.annotations', '1.0') {
exports('net.jcip.annotations')
}
module("netlayer-jpms-${osName}${targetName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
exports('org.berndpruenster.netlayer.tor')
requires('com.github.ravn.jsocks')
requires('com.github.JesusMcCloud.jtorctl')
module('thumbnailator-0.4.18.jar', 'net.coobird.thumbnailator', '0.4.18') {
exports('net.coobird.thumbnailator')
requires('java.desktop')
}
module('fxsvgimage-1.0b2.jar', 'com.github.hervegirod', '1.0b2') {
exports('org.girod.javafx.svgimage')
requires('javafx.graphics')
requires('java.xml')
}
module("kmp-tor-jvm-${vKmpTor}.jar", 'kmp.tor.jvm', "${vTor}-${vKmpTor}") {
exports('io.matthewnelson.kmp.tor')
requires('kmp.tor.binary.extract.jvm')
requires('kmp.tor.manager.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('commons.compress')
requires('org.tukaani.xz')
requires('kotlinx.coroutines.core')
requires('java.management')
requires('io.github.microutils.kotlin.logging')
}
module('jtorctl-1.5.jar', 'com.github.JesusMcCloud.jtorctl', '1.5') {
exports('net.freehaven.tor.control')
if(kmpOs == "linux" && kmpArch == "arm64") {
module("kmp-tor-binary-${kmpOs}${kmpArch}-jvm-${vTor}.jar", "kmp.tor.binary.${kmpOs}${kmpArch}", "${vTor}") {
exports("io.matthewnelson.kmp.tor.resource.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
} else {
module("kmp-tor-binary-${kmpOs}${kmpArch}-jvm-${vTor}.jar", "kmp.tor.binary.${kmpOs}${kmpArch}", "${vTor}") {
exports("io.matthewnelson.kmp.tor.binary.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
}
module('commons-compress-1.18.jar', 'commons.compress', '1.18') {
exports('org.apache.commons.compress')
requires('org.tukaani.xz')
module("kmp-tor-binary-extract-jvm-${vTor}.jar", 'kmp.tor.binary.extract.jvm', "${vTor}") {
exports('io.matthewnelson.kmp.tor.binary.extract')
exports('io.matthewnelson.kmp.tor.binary.extract.internal')
requires('kotlin.stdlib')
requires("kmp.tor.binary.${kmpOs}${kmpArch}")
requires('kmp.tor.binary.geoip.jvm')
}
module('xz-1.6.jar', 'org.tukaani.xz', '1.6') {
exports('org.tukaani.xz')
module("kmp-tor-manager-jvm-${vKmpTor}.jar", 'kmp.tor.manager.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.manager')
exports('io.matthewnelson.kmp.tor.manager.util')
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core')
requires('kotlinx.atomicfu')
requires('kmp.tor.controller.jvm')
requires('kmp.tor.common.jvm')
}
module('jsocks-1.0.jar', 'com.github.ravn.jsocks', '1.0') {
exports('com.runjva.sourceforge.jsocks.protocol')
requires('org.slf4j')
module("kmp-tor-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.manager.common')
exports('io.matthewnelson.kmp.tor.manager.common.event')
exports('io.matthewnelson.kmp.tor.manager.common.state')
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.common.jvm')
requires('kotlin.stdlib')
}
module("kmp-tor-controller-common-jvm-${vKmpTor}.jar", 'kmp.tor.controller.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.controller.common.config')
exports('io.matthewnelson.kmp.tor.controller.common.file')
exports('io.matthewnelson.kmp.tor.controller.common.control')
exports('io.matthewnelson.kmp.tor.controller.common.control.usecase')
exports('io.matthewnelson.kmp.tor.controller.common.events')
exports('io.matthewnelson.kmp.tor.controller.common.exceptions')
requires('kmp.tor.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
}
module("kmp-tor-common-jvm-${vKmpTor}.jar", 'kmp.tor.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.common.address')
requires('parcelize.jvm')
requires('kotlin.stdlib')
}
module("kmp-tor-controller-jvm-${vKmpTor}.jar", 'kmp.tor.controller.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.controller.internal.controller')
requires('kmp.tor.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlinx.coroutines.core')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
requires('encoding.core.jvm')
requires('encoding.base16.jvm')
}
module("kmp-tor-ext-callback-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.common')
}
module("kmp-tor-ext-callback-manager-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager')
requires('kmp.tor.manager.jvm')
requires('kmp.tor.ext.callback.common.jvm')
requires('kmp.tor.ext.callback.manager.common.jvm')
requires('kmp.tor.ext.callback.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core')
}
module("kmp-tor-ext-callback-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager.common')
requires('kmp.tor.ext.callback.controller.common.jvm')
}
module("kmp-tor-ext-callback-controller-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.controller.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control')
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control.usecase')
}
module("kmp-tor-binary-geoip-jvm-${vTor}.jar", 'kmp.tor.binary.geoip.jvm', "${vTor}") {
exports('io.matthewnelson.kmp.tor.binary.geoip')
exports('kmptor')
}
module("base16-jvm-2.0.0.jar", 'encoding.base16.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.base16')
requires('encoding.core.jvm')
requires('kotlin.stdlib')
}
module("base32-jvm-2.0.0.jar", 'encoding.base32.jvm', "2.0.0")
module("base64-jvm-2.0.0.jar", 'encoding.base64.jvm', "2.0.0")
module("core-jvm-2.0.0.jar", 'encoding.core.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.core')
requires('kotlin.stdlib')
}
module("parcelize-jvm-0.1.2.jar", 'parcelize.jvm', "0.1.2") {
exports('io.matthewnelson.component.parcelize')
}
module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0')
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
+27 -4
View File
@@ -79,16 +79,39 @@ sudo apt install -y rpm fakeroot binutils
### Building the binaries
The project can cloned for a specific release tag as follows:
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="1.8.0"
```
The project can then be initially cloned as follows:
```shell
GIT_TAG="1.7.0"
git clone --recursive --branch "${GIT_TAG}" https://github.com/sparrowwallet/sparrow.git
```
Thereafter, building should be straightforward:
If you already have the sparrow repo cloned, fetch all new updates and checkout the release. For this, change into your local sparrow folder and execute:
```shell
cd sparrow
cd {yourPathToSparrow}/sparrow
git pull --recurse-submodules
git checkout "${GIT_TAG}"
```
Note - there is an additional step if you updated rather than initially cloned your repo at `GIT_TAG`.
This is due to the [drongo submodule](https://github.com/sparrowwallet/drongo/tree/master) which needs to be checked out to the commit state it had at the time of the release.
Only then your build will be comparable to the provided one in the release section of Github.
To checkout the submodule to the correct commit for `GIT_TAG`, additionally run:
```shell
git submodule update --checkout
```
Thereafter, building should be straightforward. If not already done, change into the sparrow folder and run:
```shell
cd {yourPathToSparrow}/sparrow # if you aren't already in the sparrow folder
./gradlew jpackage
```
+1 -1
Submodule drongo updated: 7c34ec7c3b...0bb5b75be5
Binary file not shown.
+2 -1
View File
@@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+8 -4
View File
@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,10 +80,10 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# 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"'
@@ -143,12 +143,16 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
Vendored
+1
View File
@@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -5,5 +5,6 @@ Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
Categories=Finance;Network;
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
StartupWMClass=Sparrow
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.7.0</string>
<string>1.8.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -107,6 +107,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem exportWallet;
@FXML
private MenuItem renameWallet;
@FXML
private MenuItem deleteWallet;
@@ -166,6 +169,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem lockAllWallets;
@FXML
private MenuItem showWalletSummary;
@FXML
private MenuItem searchWallet;
@@ -210,6 +216,8 @@ public class AppController implements Initializable {
private Timeline statusTimeline;
private SendToManyDialog sendToManyDialog;
private Tab previouslySelectedTab;
private boolean subTabsVisible;
@@ -218,10 +226,47 @@ public class AppController implements Initializable {
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
private final Map<File, File> renamedWallets = new HashMap<>();
private final ChangeListener<Boolean> serverToggleOnlineListener = (observable, oldValue, newValue) -> {
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
};
private final ListChangeListener<Tab> tabsChangeListener = (c) -> {
if(c.next() && (c.wasAdded() || c.wasRemoved())) {
if(c.wasRemoved() && previouslySelectedTab != null) {
tabs.getSelectionModel().select(previouslySelectedTab);
}
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData) tab.getUserData()).getType() == TabData.TabType.WALLET);
boolean walletRemoved = c.getRemoved().stream().anyMatch(tab -> ((TabData) tab.getUserData()).getType() == TabData.TabType.WALLET);
if(walletAdded || walletRemoved) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
}
List<WalletTabData> closedWalletTabs = c.getRemoved().stream().filter(tab -> tab.getUserData() instanceof WalletTabData)
.flatMap(tab -> ((TabPane) tab.getContent()).getTabs().stream().map(subTab -> (WalletTabData) subTab.getUserData())).collect(Collectors.toList());
if(!closedWalletTabs.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedWalletTabs));
}
List<TransactionTabData> closedTransactionTabs = c.getRemoved().stream().map(tab -> (TabData) tab.getUserData())
.filter(tabData -> tabData.getType() == TabData.TabType.TRANSACTION).map(tabData -> (TransactionTabData) tabData).collect(Collectors.toList());
if(!closedTransactionTabs.isEmpty()) {
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);
}
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -278,46 +323,15 @@ public class AppController implements Initializable {
//Draggle tabs introduce unwanted movement when selecting between them
//tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER);
tabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
if(c.next() && (c.wasAdded() || c.wasRemoved())) {
if(c.wasRemoved() && previouslySelectedTab != null) {
tabs.getSelectionModel().select(previouslySelectedTab);
}
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
boolean walletRemoved = c.getRemoved().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
if(walletAdded || walletRemoved) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
}
List<WalletTabData> closedWalletTabs = c.getRemoved().stream().filter(tab -> tab.getUserData() instanceof WalletTabData)
.flatMap(tab -> ((TabPane)tab.getContent()).getTabs().stream().map(subTab -> (WalletTabData)subTab.getUserData())).collect(Collectors.toList());
if(!closedWalletTabs.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedWalletTabs));
}
List<TransactionTabData> closedTransactionTabs = c.getRemoved().stream().map(tab -> (TabData)tab.getUserData())
.filter(tabData -> tabData.getType() == TabData.TabType.TRANSACTION).map(tabData -> (TransactionTabData)tabData).collect(Collectors.toList());
if(!closedTransactionTabs.isEmpty()) {
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);
}
}
});
tabs.getTabs().addListener(tabsChangeListener);
tabs.getScene().getWindow().setOnCloseRequest(event -> {
EventManager.get().unregister(this);
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList()));
});
registerShortcuts();
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null) {
unit = BitcoinUnit.AUTO;
@@ -326,6 +340,9 @@ public class AppController implements Initializable {
final BitcoinUnit selectedUnit = unit;
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
Optional<Toggle> otherUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle ->
(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(selectedUnit) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (selectedUnit == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData())))).findFirst();
otherUnitToggle.ifPresent(toggle -> ((RadioMenuItem)toggle).setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN)));
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
@@ -368,9 +385,11 @@ public class AppController implements Initializable {
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
showPSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
exportWallet.setDisable(true);
renameWallet.disableProperty().bind(exportWallet.disableProperty());
deleteWallet.disableProperty().bind(exportWallet.disableProperty());
closeTab.setDisable(true);
lockWallet.setDisable(true);
showWalletSummary.disableProperty().bind(exportWallet.disableProperty());
searchWallet.disableProperty().bind(exportWallet.disableProperty());
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
sendToMany.disableProperty().bind(exportWallet.disableProperty());
@@ -384,7 +403,7 @@ public class AppController implements Initializable {
configureSwitchServer();
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
serverToggle.setDisable(Config.get().getServerType() == null);
serverToggle.setDisable(!Config.get().hasServer());
onlineProperty().bindBidirectional(serverToggle.selectedProperty());
onlineProperty().addListener(new WeakChangeListener<>(serverToggleOnlineListener));
serverToggle.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
@@ -395,6 +414,22 @@ public class AppController implements Initializable {
setNetworkLabel();
}
private void registerShortcuts() {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
tabs.getScene().addEventFilter(KeyEvent.KEY_PRESSED, event -> {
if(event.isShortcutDown() && event.isAltDown() && (event.getCode() == KeyCode.LEFT || event.getCode() == KeyCode.RIGHT)) {
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
if(event.getCode() == KeyCode.LEFT && currentIndex > 0) {
tabs.getSelectionModel().select(currentIndex - 1);
} else if(event.getCode() == KeyCode.RIGHT && currentIndex < tabs.getTabs().size() - 1) {
tabs.getSelectionModel().select(currentIndex + 1);
}
}
});
}
}
private void setPlatformApplicationMenu() {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
@@ -438,6 +473,7 @@ public class AppController implements Initializable {
public void showIntroduction(ActionEvent event) {
WelcomeDialog welcomeDialog = new WelcomeDialog();
welcomeDialog.initOwner(rootStack.getScene().getWindow());
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
openPreferences(PreferenceGroup.SERVER);
@@ -462,7 +498,17 @@ public class AppController implements Initializable {
}
public void submitBugReport(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
ButtonType supportType = new ButtonType("Get Support", ButtonBar.ButtonData.LEFT);
ButtonType bugType = new ButtonType("Submit Bug Report", ButtonBar.ButtonData.YES);
Optional<ButtonType> optResponse = showWarningDialog("Submit Bug Report", "Please note that this facility is for bug reports and feature requests only. There is a community of Sparrow users who can assist with support requests.", supportType, bugType);
if(optResponse.isPresent()) {
if(optResponse.get() == bugType) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
} else {
openSupport(event);
}
}
}
public void showAbout(ActionEvent event) {
@@ -515,7 +561,8 @@ public class AppController implements Initializable {
}
public void showInstallUdevMessage() {
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules");
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules", false);
dialog.initOwner(rootStack.getScene().getWindow());
dialog.setTitle("Install Udev Rules");
dialog.getDialogPane().setHeaderText("Installing udev rules ensures devices can connect over USB.\nThis command requires root privileges.\nOpen a shell and enter the following:");
dialog.showAndWait();
@@ -533,9 +580,11 @@ public class AppController implements Initializable {
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if (file != null) {
openTransactionFile(file);
List<File> files = fileChooser.showOpenMultipleDialog(window);
if(files != null) {
for(File file : files) {
openTransactionFile(file);
}
}
}
@@ -584,6 +633,7 @@ public class AppController implements Initializable {
public void openTransactionFromText(ActionEvent event) {
TextAreaDialog dialog = new TextAreaDialog();
dialog.initOwner(rootStack.getScene().getWindow());
dialog.setTitle("Open from text");
dialog.getDialogPane().setHeaderText("Paste a transaction or PSBT:");
Optional<String> text = dialog.showAndWait();
@@ -602,6 +652,7 @@ public class AppController implements Initializable {
public void openTransactionFromId(ActionEvent event) {
TransactionIdDialog dialog = new TransactionIdDialog();
dialog.initOwner(rootStack.getScene().getWindow());
Optional<Sha256Hash> optionalTxId = dialog.showAndWait();
if(optionalTxId.isPresent()) {
Sha256Hash txId = optionalTxId.get();
@@ -629,6 +680,7 @@ public class AppController implements Initializable {
public void openTransactionFromQR(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(rootStack.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -692,6 +744,7 @@ public class AppController implements Initializable {
try {
UR ur = UR.fromBytes(transaction.bitcoinSerialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur);
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
qrDisplayDialog.showAndWait();
} catch(Exception e) {
log.error("Error creating UR", e);
@@ -751,7 +804,7 @@ public class AppController implements Initializable {
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
writer.flush();
} else {
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs));
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs, true));
}
} catch(IOException e) {
log.error("Error saving PSBT", e);
@@ -790,6 +843,7 @@ public class AppController implements Initializable {
CryptoPSBT cryptoPSBT = new CryptoPSBT(transactionTabData.getPsbt().serialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR());
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
qrDisplayDialog.show();
}
}
@@ -834,6 +888,10 @@ public class AppController implements Initializable {
}
}
public void renameWallet(ActionEvent event) {
renameWallet(getSelectedWalletForm());
}
public void deleteWallet(ActionEvent event) {
deleteWallet(getSelectedWalletForm());
}
@@ -942,6 +1000,8 @@ public class AppController implements Initializable {
if(AppServices.isConnected()) {
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." : "");
} else if(AppServices.isConnecting()) {
return "Connecting...";
}
return "Disconnected";
@@ -949,6 +1009,7 @@ public class AppController implements Initializable {
public void newWallet(ActionEvent event) {
WalletNameDialog dlg = new WalletNameDialog();
dlg.initOwner(rootStack.getScene().getWindow());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = dlg.showAndWait();
if(optNameAndBirthDate.isPresent()) {
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
@@ -970,9 +1031,11 @@ public class AppController implements Initializable {
fileChooser.setInitialDirectory(Storage.getWalletsDir());
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
openWalletFile(file, forceSameWindow);
List<File> files = fileChooser.showOpenMultipleDialog(window);
if(files != null) {
for(File file : files) {
openWalletFile(file, forceSameWindow);
}
}
}
@@ -998,6 +1061,7 @@ public class AppController implements Initializable {
loadWalletService.start();
} else {
WalletPasswordDialog dlg = new WalletPasswordDialog(storage.getWalletName(null), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> optionalPassword = dlg.showAndWait();
if(optionalPassword.isEmpty()) {
return;
@@ -1064,11 +1128,26 @@ public class AppController implements Initializable {
}
public void importWallet(ActionEvent event) {
WalletImportDialog dlg = new WalletImportDialog();
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get();
addImportedWallet(wallet);
List<WalletTabData> walletTabData = getOpenWalletTabData();
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
if(optNewWalletForm.isPresent()) {
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
selectTab(optNewWalletForm.get().getWallet());
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
}
}
}
@@ -1089,6 +1168,7 @@ public class AppController implements Initializable {
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> optionalPassword = dlg.showAndWait();
if(optionalPassword.isPresent()) {
password = optionalPassword.get();
@@ -1110,7 +1190,8 @@ public class AppController implements Initializable {
}
private void addImportedWallet(Wallet wallet) {
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName());
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getBirthDate());
nameDlg.initOwner(rootStack.getScene().getWindow());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
if(optNameAndBirthDate.isPresent()) {
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
@@ -1156,6 +1237,7 @@ public class AppController implements Initializable {
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
if(password.get().length() == 0) {
@@ -1221,7 +1303,8 @@ public class AppController implements Initializable {
public void exportWallet(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm.getWallet());
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> wallet = dlg.showAndWait();
if(wallet.isPresent()) {
//Successful export
@@ -1239,8 +1322,10 @@ public class AppController implements Initializable {
private void openPreferences(PreferenceGroup preferenceGroup) {
PreferencesDialog preferencesDialog = new PreferencesDialog(preferenceGroup);
preferencesDialog.initOwner(rootStack.getScene().getWindow());
preferencesDialog.showAndWait();
configureSwitchServer();
serverToggle.setDisable(!Config.get().hasServer());
}
public void signVerifyMessage(ActionEvent event) {
@@ -1248,8 +1333,7 @@ public class AppController implements Initializable {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
Wallet wallet = selectedWalletForm.getWallet();
if(wallet.getKeystores().size() == 1 &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
if(wallet.getKeystores().size() == 1) {
//Can sign and verify
messageSignDialog = new MessageSignDialog(wallet);
}
@@ -1260,10 +1344,18 @@ public class AppController implements Initializable {
messageSignDialog = new MessageSignDialog();
}
messageSignDialog.initOwner(rootStack.getScene().getWindow());
messageSignDialog.showAndWait();
}
public void sendToMany(ActionEvent event) {
if(sendToManyDialog != null) {
Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow();
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
return;
}
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
Wallet wallet = selectedWalletForm.getWallet();
@@ -1272,11 +1364,14 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit();
}
SendToManyDialog sendToManyDialog = new SendToManyDialog(bitcoinUnit);
sendToManyDialog = new SendToManyDialog(bitcoinUnit);
sendToManyDialog.initOwner(rootStack.getScene().getWindow());
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
optPayments.ifPresent(payments -> {
if(!payments.isEmpty()) {
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet())));
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getSpendableUtxos().keySet())));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(wallet, payments)));
}
});
@@ -1291,6 +1386,7 @@ public class AppController implements Initializable {
}
PrivateKeySweepDialog dialog = new PrivateKeySweepDialog(wallet);
dialog.initOwner(rootStack.getScene().getWindow());
Optional<Transaction> optTransaction = dialog.showAndWait();
optTransaction.ifPresent(transaction -> addTransactionTab(null, null, transaction));
}
@@ -1304,6 +1400,7 @@ public class AppController implements Initializable {
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = selectedWalletForm.getStorage();
@@ -1317,6 +1414,7 @@ public class AppController implements Initializable {
try {
soroban.setHDWallet(copy);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
@@ -1344,6 +1442,7 @@ public class AppController implements Initializable {
} else {
soroban.setHDWallet(wallet);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
@@ -1351,6 +1450,7 @@ public class AppController implements Initializable {
}
} else {
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
@@ -1363,6 +1463,7 @@ public class AppController implements Initializable {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId());
payNymDialog.initOwner(rootStack.getScene().getWindow());
payNymDialog.showAndWait();
}
}
@@ -1408,13 +1509,52 @@ public class AppController implements Initializable {
TabPane subTabs = (TabPane) selectedTab.getContent();
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
if(!walletForms.isEmpty()) {
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
if(optEntry.isPresent()) {
Entry entry = optEntry.get();
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
}
searchWallets(walletForms);
}
}
}
}
public void searchAllWallets(ActionEvent event) {
List<WalletForm> allWalletForms = new ArrayList<>();
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData) {
TabPane subTabs = (TabPane)tab.getContent();
allWalletForms.addAll(subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm())
.filter(walletForm -> walletForm.getWallet().isValid() && !walletForm.isLocked()).collect(Collectors.toList()));
}
}
if(allWalletForms.isEmpty()) {
showErrorDialog("No wallets", "There are no open and unlocked wallets to search.");
} else {
searchWallets(allWalletForms);
}
}
private void searchWallets(List<WalletForm> walletForms) {
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
searchWalletDialog.initOwner(rootStack.getScene().getWindow());
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
if(optEntry.isPresent()) {
Entry entry = optEntry.get();
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
}
}
public void showWalletSummary(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
if(selectedTab != null) {
TabData tabData = (TabData) selectedTab.getUserData();
if(tabData instanceof WalletTabData) {
TabPane subTabs = (TabPane) selectedTab.getContent();
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
if(!walletForms.isEmpty()) {
WalletSummaryDialog walletSummaryDialog = new WalletSummaryDialog(walletForms);
walletSummaryDialog.initOwner(rootStack.getScene().getWindow());
walletSummaryDialog.showAndWait();
}
}
}
@@ -1461,11 +1601,10 @@ public class AppController implements Initializable {
wallet.setName(name);
}
Tab tab = new Tab("");
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
glyph.setFontSize(10.0);
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
WalletIcon walletIcon = new WalletIcon(storage, wallet);
walletIcon.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
Label tabLabel = new Label(name);
tabLabel.setGraphic(glyph);
tabLabel.setGraphic(walletIcon);
tabLabel.setGraphicTextGap(5.0);
tab.setGraphic(tabLabel);
tab.setClosable(true);
@@ -1506,6 +1645,11 @@ public class AppController implements Initializable {
tabs.getTabs().add(tab);
tabs.getSelectionModel().select(tab);
File oldWalletFile = renamedWallets.remove(storage.getWalletFile());
if(oldWalletFile != null) {
deleteStorage(new Storage(oldWalletFile), false);
}
} else {
for(Tab walletTab : tabs.getTabs()) {
TabData tabData = (TabData)walletTab.getUserData();
@@ -1582,6 +1726,9 @@ public class AppController implements Initializable {
subTabLabel.setGraphic(getSubTabGlyph(wallet));
subTabLabel.setContentDisplay(ContentDisplay.TOP);
subTabLabel.setAlignment(Pos.TOP_CENTER);
if(isSubTabLabelTruncated(subTabLabel, label)) {
subTabLabel.setTooltip(new Tooltip(label));
}
subTab.setGraphic(subTabLabel);
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
subTab.setContent(walletLoader.load());
@@ -1597,7 +1744,7 @@ public class AppController implements Initializable {
TabData tabData = new WalletTabData(TabData.TabType.WALLET, walletForm);
subTab.setUserData(tabData);
if(!wallet.isWhirlpoolChildWallet()) {
subTab.setContextMenu(getSubTabContextMenu(wallet, subTabs, subTab));
subTab.setContextMenu(getSubTabContextMenu(storage, wallet, subTabs, subTab));
}
subTabs.getTabs().add(subTab);
@@ -1608,6 +1755,10 @@ public class AppController implements Initializable {
});
subTabs.getSelectionModel().select(subTab);
if(wallet.isValid()) {
Platform.runLater(() -> walletForm.refreshHistory(AppServices.getCurrentBlockHeight()));
}
return walletForm;
} catch(IOException e) {
throw new RuntimeException(e);
@@ -1646,6 +1797,19 @@ public class AppController implements Initializable {
return null;
}
public List<WalletForm> getSelectedWalletForms() {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
if(selectedTab != null) {
TabData tabData = (TabData) selectedTab.getUserData();
if(tabData instanceof WalletTabData) {
TabPane subTabs = (TabPane) selectedTab.getContent();
return subTabs.getTabs().stream().map(subTab -> ((WalletTabData) subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
}
}
return Collections.emptyList();
}
private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException {
if(Utils.isBase64(string) && !Utils.isHex(string)) {
addTransactionTab(name, file, Base64.getDecoder().decode(string));
@@ -1813,16 +1977,22 @@ public class AppController implements Initializable {
MenuItem moveRight = new MenuItem("Move Right");
moveRight.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index + 1, tab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
});
MenuItem moveLeft = new MenuItem("Move Left");
moveLeft.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index - 1, tab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
});
contextMenu.getItems().addAll(moveRight, moveLeft);
@@ -1850,23 +2020,93 @@ public class AppController implements Initializable {
contextMenu.getItems().addAll(new SeparatorMenuItem(), close, closeOthers, closeAll);
if(tab.getUserData() instanceof WalletTabData) {
if(tab.getUserData() instanceof WalletTabData walletTabData) {
Menu walletIcon = new Menu("Wallet Icon");
MenuItem custom = new MenuItem("Custom...");
custom.setOnAction(event -> {
setCustomIcon(walletTabData.getWallet());
});
MenuItem reset = new MenuItem("Reset");
reset.setOnAction(event -> {
resetIcon(walletTabData.getWalletForm());
});
walletIcon.getItems().addAll(custom, reset);
MenuItem delete = new MenuItem("Delete...");
delete.setOnAction(event -> {
deleteWallet(getSelectedWalletForm());
deleteWallet(walletTabData.getWalletForm());
});
contextMenu.getItems().addAll(new SeparatorMenuItem(), delete);
contextMenu.getItems().addAll(new SeparatorMenuItem(), walletIcon, delete);
}
return contextMenu;
}
private void setCustomIcon(Wallet wallet) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open Image");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("Images", "*.png", "*.jpg", "*.jpeg", "*.gif")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
try {
byte[] iconData = ImageUtils.resize(file, WalletIcon.SAVE_WIDTH, WalletIcon.SAVE_HEIGHT);
WalletConfig walletConfig = wallet.getMasterWalletConfig();
walletConfig.setIconData(iconData, true);
EventManager.get().post(new WalletConfigChangedEvent(wallet));
} catch(Exception e) {
log.error("Error creating custom wallet icon", e);
showErrorDialog("Error creating custom wallet icon", e.getMessage());
}
}
}
private void resetIcon(WalletForm walletForm) {
Wallet masterWallet = walletForm.getMasterWallet();
if(masterWallet.getWalletConfig() != null && masterWallet.getWalletConfig().isUserIcon()) {
masterWallet.getWalletConfig().setIconData(null, false);
EventManager.get().post(new WalletConfigChangedEvent(masterWallet));
}
}
private void renameWallet(WalletForm selectedWalletForm) {
WalletNameDialog walletNameDialog = new WalletNameDialog(selectedWalletForm.getMasterWallet().getName(), false, null, true);
walletNameDialog.initOwner(rootStack.getScene().getWindow());
Optional<WalletNameDialog.NameAndBirthDate> optName = walletNameDialog.showAndWait();
if(optName.isPresent()) {
File walletFile = Storage.getWalletFile(optName.get().getName() + "." + PersistenceType.DB.getExtension());
if(walletFile.exists()) {
showErrorDialog("Error renaming wallet", "Wallet file " + walletFile.getAbsolutePath() + " already exists.");
return;
}
Storage.CopyWalletService copyWalletService = new Storage.CopyWalletService(selectedWalletForm.getWallet(), walletFile);
copyWalletService.setOnSucceeded(event -> {
renamedWallets.put(walletFile, selectedWalletForm.getStorage().getWalletFile());
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
openWalletFile(walletFile, true);
});
copyWalletService.setOnFailed(event -> {
log.error("Error renaming wallet", event.getSource().getException());
showErrorDialog("Error renaming wallet", event.getSource().getException().getMessage());
});
copyWalletService.start();
}
}
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);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
@@ -1876,7 +2116,7 @@ public class AppController implements Initializable {
try {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
deleteStorage(storage);
deleteStorage(storage, true);
} finally {
encryptionFullKey.clear();
password.get().clear();
@@ -1898,32 +2138,56 @@ public class AppController implements Initializable {
}
} else {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
deleteStorage(storage);
deleteStorage(storage, true);
}
}
}
private void deleteStorage(Storage storage) {
private void deleteStorage(Storage storage, boolean deleteBackups) {
if(storage.isClosed()) {
Platform.runLater(storage::delete);
Platform.runLater(() -> {
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage, deleteBackups);
deleteWalletService.setDelay(Duration.seconds(3));
deleteWalletService.setPeriod(Duration.hours(1));
deleteWalletService.setOnSucceeded(event -> {
deleteWalletService.cancel();
if(!deleteWalletService.getValue()) {
showErrorDialog("Error deleting wallet", "Could not delete " + storage.getWalletFile().getName() + ". Please delete this file manually.");
}
});
deleteWalletService.setOnFailed(event -> {
deleteWalletService.cancel();
showErrorDialog("Error deleting wallet", "Could not delete " + storage.getWalletFile().getName() + ". Please delete this file manually.");
});
deleteWalletService.start();
});
} else {
Platform.runLater(() -> deleteStorage(storage));
Platform.runLater(() -> deleteStorage(storage, deleteBackups));
}
}
private ContextMenu getSubTabContextMenu(Wallet wallet, TabPane subTabs, Tab subTab) {
private ContextMenu getSubTabContextMenu(Storage storage, Wallet wallet, TabPane subTabs, Tab subTab) {
String walletId = storage.getWalletId(wallet);
ContextMenu contextMenu = new ContextMenu();
MenuItem rename = new MenuItem("Rename Account");
rename.setOnAction(event -> {
Label subTabLabel = (Label)subTab.getGraphic();
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(subTabLabel.getText());
walletLabelDialog.initOwner(rootStack.getScene().getWindow());
Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) {
String label = optLabel.get();
subTabLabel.setText(label);
if(isSubTabLabelTruncated(subTabLabel, label)) {
subTabLabel.setTooltip(new Tooltip(label));
} else {
subTabLabel.setTooltip(null);
}
wallet.setLabel(label);
EventManager.get().post(new WalletLabelChangedEvent(wallet));
Wallet renamedWallet = AppServices.get().getWallet(walletId);
renamedWallet.setLabel(label);
EventManager.get().post(new WalletLabelChangedEvent(renamedWallet));
}
});
contextMenu.getItems().add(rename);
@@ -1933,19 +2197,29 @@ public class AppController implements Initializable {
delete.setOnAction(event -> {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete Wallet Account?", "Labels applied in this wallet account will be lost. Are you sure?", ButtonType.CANCEL, ButtonType.OK);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.OK) {
Wallet deletedWallet = AppServices.get().getWallet(walletId);
EventManager.get().post(new WalletDeletedEvent(deletedWallet));
subTabs.getTabs().remove(subTab);
if(subTabs.getTabs().size() == 1) {
setSubTabsVisible(subTabs, areSubTabsVisible());
}
EventManager.get().post(new WalletDeletedEvent(wallet));
}
});
contextMenu.getItems().add(delete);
}
contextMenu.setOnShowing(event -> {
Wallet renameWallet = AppServices.get().getWallet(walletId);
rename.setDisable(!renameWallet.isValid());
});
return contextMenu;
}
private boolean isSubTabLabelTruncated(Label subTabLabel, String label) {
return TextUtils.computeTextWidth(subTabLabel.getFont(), label, 0.0D) > (90-6);
}
private void configureSwitchServer() {
switchServer.getItems().clear();
@@ -2001,7 +2275,7 @@ public class AppController implements Initializable {
serverToggle.getStyleClass().remove("core-server");
}
serverToggle.setDisable(false);
serverToggle.setDisable(!Config.get().hasServer());
}
public void setTheme(ActionEvent event) {
@@ -2050,8 +2324,8 @@ public class AppController implements Initializable {
private void tabLabelAddFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
if(!tabLabel.getStyleClass().contains("failure")) {
tabLabel.getGraphic().getStyleClass().add("failure");
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
if(walletIcon.addFailure()) {
tabLabel.setTooltip(new Tooltip("Error loading transaction history from server"));
}
}
@@ -2084,7 +2358,8 @@ public class AppController implements Initializable {
private void tabLabelRemoveFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
tabLabel.getGraphic().getStyleClass().remove("failure");
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.removeFailure();
tabLabel.setTooltip(null);
}
@@ -2121,6 +2396,14 @@ public class AppController implements Initializable {
} else {
tabs.getScene().getStylesheets().remove(darkCss);
}
for(Tab tab : tabs.getTabs()) {
if(tab.getUserData() instanceof WalletTabData) {
Label tabLabel = (Label)tab.getGraphic();
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.refresh();
}
}
}
@Subscribe
@@ -2201,6 +2484,9 @@ public class AppController implements Initializable {
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());
Label tabLabel = (Label)walletTab.getGraphic();
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.setWallet(event.getWallet());
}
}
}
@@ -2511,6 +2797,50 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void cormorantSyncStatus(CormorantSyncStatusEvent event) {
serverToggle.setDisable(false);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Syncing... (" + event.getProgress() + "% complete, synced to " + event.getTipAsString() + ")"));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
}
}
@Subscribe
public void cormorantScanStatus(CormorantScanStatusEvent event) {
serverToggle.setDisable(true);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Scanning... (" + event.getProgress() + "% complete" + (event.getRemainingAsString().isEmpty() ? ")" : ", " + event.getRemainingAsString() + " remaining)")));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
} else if(event.isCompleted()) {
serverToggle.setDisable(false);
if(statusBar.getText().startsWith("Scanning...")) {
statusBar.setText("");
}
}
}
@Subscribe
public void cormorantPruneStatus(CormorantPruneStatusEvent event) {
if(event.legacyWalletExists()) {
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".\n\n" +
"Do you want to try using the existing legacy Bitcoin Core wallet?", ButtonType.YES, ButtonType.NO);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
Config.get().setUseLegacyCoreWallet(true);
onlineProperty().set(false);
Platform.runLater(() -> onlineProperty().set(true));
}
} else {
AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".");
}
}
@Subscribe
public void bwtBootStatus(BwtBootStatusEvent event) {
serverToggle.setDisable(true);
@@ -2639,6 +2969,14 @@ public class AppController implements Initializable {
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
Optional<Toggle> selectedToggle = bitcoinUnit.getToggles().stream().filter(toggle -> event.getBitcoinUnit().equals(toggle.getUserData())).findFirst();
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
bitcoinUnit.getToggles().forEach(toggle -> {
RadioMenuItem menuItem = (RadioMenuItem)toggle;
if(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(event.getBitcoinUnit()) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (event.getBitcoinUnit() == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData()))) {
menuItem.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN));
} else {
menuItem.setAccelerator(null);
}
});
}
@Subscribe
@@ -2728,4 +3066,17 @@ public class AppController implements Initializable {
lockAllWallets.setDisable(false);
}
}
@Subscribe
public void walletConfigChanged(WalletConfigChangedEvent event) {
for(Tab tab : tabs.getTabs()) {
if(tab.getUserData() instanceof WalletTabData walletTabData) {
if(walletTabData.getWallet() == event.getWallet()) {
Label tabLabel = (Label)tab.getGraphic();
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.refresh();
}
}
}
}
}
@@ -24,7 +24,6 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Application;
@@ -50,7 +49,6 @@ import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -64,6 +62,7 @@ import java.io.IOException;
import java.net.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
@@ -84,8 +83,10 @@ public class AppServices {
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);
public static final List<Long> LONG_FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L, 2048L, 4096L, 8192L);
public static final List<Long> FEE_RATES_RANGE = LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
private static AppServices INSTANCE;
@@ -95,7 +96,7 @@ public class AppServices {
private InteractionServices interactionServices;
private static PayNymService payNymService;
private static HttpClientService httpClientService;
private final Application application;
@@ -246,13 +247,13 @@ public class AppServices {
versionCheckService.cancel();
}
if(payNymService != null) {
PayNymService.ShutdownService shutdownService = new PayNymService.ShutdownService(payNymService);
if(httpClientService != null) {
HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
shutdownService.start();
}
if(Tor.getDefault() != null) {
Tor.getDefault().shutdown();
Tor.getDefault().getTorManager().destroy(true, success -> {});
}
}
@@ -284,26 +285,25 @@ public class AppServices {
});
connectionService.setOnFailed(failEvent -> {
//Close connection here to create a new transport next time we try
connectionService.resetConnection();
connectionService.closeConnection();
if(failEvent.getSource().getException() instanceof ServerConfigException) {
connectionService.setRestartOnFailure(false);
}
if(failEvent.getSource().getException() instanceof TlsServerException && failEvent.getSource().getException().getCause() != null) {
TlsServerException tlsServerException = (TlsServerException)failEvent.getSource().getException();
if(failEvent.getSource().getException() instanceof TlsServerException tlsServerException && failEvent.getSource().getException().getCause() != null) {
connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert();
if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
"\n\nChange the configured server certificate if you would like to proceed.");
} else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(crtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
if(crtFile.delete()) {
@@ -315,10 +315,19 @@ public class AppServices {
}
}
}
} else if(tlsServerException.getCause().getCause() instanceof UnknownCertificateExpiredException expiredException) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " has expired. "
+ tlsServerException.getMessage() + "." +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
Storage.saveCertificate(tlsServerException.getServer().getHost(), expiredException.getCertificate());
Platform.runLater(() -> restartService(connectionService));
return;
}
}
}
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().requiresTor()) {
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().isAutoSwitchProxy() && Config.get().requiresTor()) {
Config.get().setUseProxy(false);
Platform.runLater(() -> restartService(torService));
return;
@@ -406,23 +415,6 @@ public class AppServices {
EventManager.get().post(new TorReadyStatusEvent());
});
torService.setOnFailed(workerStateEvent -> {
Throwable exception = workerStateEvent.getSource().getException();
if(exception instanceof TorServerAlreadyBoundException) {
String proxyServer = Config.get().getProxyServer();
if(proxyServer == null || proxyServer.equals("")) {
proxyServer = "localhost:9050";
Config.get().setProxyServer(proxyServer);
}
if(proxyServer.equals("localhost:9050") || proxyServer.equals("127.0.0.1:9050")) {
Config.get().setUseProxy(true);
torService.cancel();
restartServices();
EventManager.get().post(new TorExternalStatusEvent());
return;
}
}
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
});
@@ -482,8 +474,7 @@ public class AppServices {
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
} else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
proxy = Tor.getDefault().getProxy();
}
//Setting new proxy authentication credentials will force a new Tor circuit to be created
@@ -522,23 +513,23 @@ public class AppServices {
return get().interactionServices;
}
public static PayNymService getPayNymService() {
if(payNymService == null) {
public static HttpClientService getHttpClientService() {
if(httpClientService == null) {
HostAndPort torProxy = getTorProxy();
payNymService = new PayNymService(torProxy);
httpClientService = new HttpClientService(torProxy);
} else {
HostAndPort torProxy = getTorProxy();
if(!Objects.equals(payNymService.getTorProxy(), torProxy)) {
payNymService.setTorProxy(getTorProxy());
if(!Objects.equals(httpClientService.getTorProxy(), torProxy)) {
httpClientService.setTorProxy(getTorProxy());
}
}
return payNymService;
return httpClientService;
}
public static HostAndPort getTorProxy() {
return AppServices.isTorRunning() ?
HostAndPort.fromParts("localhost", TorService.PROXY_PORT) :
Tor.getDefault().getProxyHostAndPort() :
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer()));
}
@@ -574,8 +565,17 @@ public class AppServices {
return windowIcon;
}
public static boolean isReducedWindowHeight() {
Window activeWindow = getActiveWindow();
return (activeWindow != null && activeWindow.getHeight() < getReducedWindowHeight());
}
public static boolean isReducedWindowHeight(Node node) {
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
return (node.getScene() != null && node.getScene().getWindow().getHeight() < getReducedWindowHeight());
}
private static double getReducedWindowHeight() {
return org.controlsfx.tools.Platform.getCurrent() != org.controlsfx.tools.Platform.OSX ? 802d : 768d; //Check for menu bar of ~34px
}
public Application getApplication() {
@@ -662,15 +662,19 @@ public class AppServices {
public static Double getDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget);
return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget);
}
public static Double getMinimumFeeRate() {
Optional<Double> optMinFeeRate = getTargetBlockFeeRates().values().stream().min(Double::compareTo);
Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE);
Double minRate = optMinFeeRate.orElse(getFallbackFeeRate());
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
public static double getFallbackFeeRate() {
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
}
public static Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
@@ -680,6 +684,10 @@ public class AppServices {
}
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
if(rateSizes.isEmpty()) {
return;
}
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
if(mempoolHistogram.isEmpty()) {
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
@@ -689,6 +697,12 @@ public class AppServices {
Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
mempoolHistogram.keySet().removeIf(date -> date.before(yesterday));
ZonedDateTime twoHoursAgo = LocalDateTime.now().minusHours(2).atZone(ZoneId.systemDefault());
mempoolHistogram.keySet().removeIf(date -> {
ZonedDateTime dateTime = date.toInstant().atZone(ZoneId.systemDefault());
return dateTime.isBefore(twoHoursAgo) && (dateTime.getMinute() % 10 != 0);
});
}
public static Double getMinimumRelayFeeRate() {
@@ -803,6 +817,24 @@ public class AppServices {
}
}
public static void openBlockExplorer(String txid) {
if(Config.get().isBlockExplorerDisabled()) {
return;
}
Server blockExplorer = Config.get().getBlockExplorer() == null ? BlockExplorer.MEMPOOL_SPACE.getServer() : Config.get().getBlockExplorer();
String url = blockExplorer.getUrl();
if(url.contains("{0}")) {
url = url.replace("{0}", txid);
} else {
if(Network.get() != Network.MAINNET) {
url += "/" + Network.get().getName();
}
url += "/tx/" + txid;
}
AppServices.get().getApplication().getHostServices().showDocument(url);
}
static void parseFileUriArguments(List<String> fileUriArguments) {
for(String fileUri : fileUriArguments) {
try {
@@ -890,7 +922,7 @@ public class AppServices {
if(wallet != null) {
final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true));
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getSpendableUtxos().keySet()), true));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
}
} catch(Exception e) {
@@ -930,6 +962,7 @@ public class AppServices {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(getActiveWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
@@ -992,6 +1025,7 @@ public class AppServices {
wallet = wallets.iterator().next();
} else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
walletChoiceDialog.initOwner(getActiveWindow());
walletChoiceDialog.setTitle("Choose Wallet");
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
Image image = new Image("/image/sparrow-small.png");
@@ -1015,8 +1049,6 @@ public class AppServices {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
@@ -1039,6 +1071,10 @@ public class AppServices {
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
}
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
@@ -1104,7 +1140,7 @@ public class AppServices {
Wallet wallet = walletTabData.getWallet();
Storage storage = walletTabData.getStorage();
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB))) {
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB) || CardApi.isReaderAvailable())) {
usbWallet = true;
if(deviceEnumerateService == null) {
@@ -1139,10 +1175,12 @@ public class AppServices {
@Subscribe
public void requestDisconnect(RequestDisconnectEvent event) {
onlineProperty.set(false);
//Ensure services don't try to reconnect
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
//Ensure services don't try to reconnect later
Platform.runLater(() -> {
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
});
}
@Subscribe
@@ -25,7 +25,7 @@ public abstract class BaseController {
scriptArea.setMouseOverTextDelay(Duration.ofMillis(150));
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
TwoDimensional.Position position = scriptArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
if(position.getMajor() % 2 == 0) {
if(position.getMajor() % 2 == 0 && scriptArea.getScript().getChunks().size() > position.getMajor() / 2) {
ScriptChunk hoverChunk = scriptArea.getScript().getChunks().get(position.getMajor()/2);
if(!hoverChunk.isOpCode()) {
Point2D pos = e.getScreenPosition();
@@ -15,13 +15,13 @@ import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
import static com.sparrowwallet.sparrow.AppServices.*;
public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
alert.initOwner(getActiveWindow());
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
@@ -53,6 +53,8 @@ public class DefaultInteractionServices implements InteractionServices {
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
alert.setResizable(true);
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@@ -60,6 +62,7 @@ public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
passphraseDialog.initOwner(getActiveWindow());
return passphraseDialog.showAndWait();
}
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.WalletIcon;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
@@ -18,6 +19,7 @@ import org.controlsfx.tools.Platform;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.URL;
import java.util.*;
import java.util.stream.Collectors;
@@ -43,6 +45,8 @@ public class SparrowDesktop extends Application {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
AppServices.initialize(this);
@@ -18,7 +18,7 @@ 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 = "1.8.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";
@@ -1,17 +0,0 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.LogHandler;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
public class TorLogHandler implements LogHandler {
private static final Logger log = LoggerFactory.getLogger(TorLogHandler.class);
@Override
public void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData) {
log.debug(message);
EventManager.get().post(new TorStatusEvent(message));
}
}
@@ -8,15 +8,21 @@ 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()));
private final DecimalFormat btcFormat = new DecimalFormat("0", getDecimalFormatSymbols());
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getSatsFormat() {
return satsFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
@@ -25,20 +31,33 @@ public enum UnitFormat {
return currencyFormat;
}
public Locale getLocale() {
return Locale.ENGLISH;
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator('.');
symbols.setGroupingSeparator(',');
return symbols;
}
},
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()));
private final DecimalFormat btcFormat = new DecimalFormat("0", getDecimalFormatSymbols());
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getSatsFormat() {
return satsFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
@@ -47,33 +66,48 @@ public enum UnitFormat {
return currencyFormat;
}
public Locale getLocale() {
return Locale.GERMAN;
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator(',');
symbols.setGroupingSeparator('.');
return symbols;
}
};
public abstract Locale getLocale();
public abstract DecimalFormatSymbols getDecimalFormatSymbols();
public abstract DecimalFormat getBtcFormat();
public abstract DecimalFormat getSatsFormat();
public abstract DecimalFormat getTableBtcFormat();
public abstract DecimalFormat getCurrencyFormat();
public abstract DecimalFormat getTableCurrencyFormat();
public String formatBtcValue(Long amount) {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String tableFormatBtcValue(Long amount) {
return getTableBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String formatSatsValue(Long amount) {
return String.format(getLocale(), "%,d", amount);
return getSatsFormat().format(amount);
}
public String formatCurrencyValue(double amount) {
return getCurrencyFormat().format(amount);
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
return DecimalFormatSymbols.getInstance(getLocale());
public String tableFormatCurrencyValue(double amount) {
return getTableCurrencyFormat().format(amount);
}
public String getGroupingSeparator() {
@@ -22,6 +22,7 @@ public class WelcomeDialog extends Dialog<Mode> {
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
@@ -17,6 +17,8 @@ import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.List;
import static com.sparrowwallet.drongo.wallet.StandardAccount.*;
public class AddAccountDialog extends Dialog<List<StandardAccount>> {
private final ComboBox<StandardAccount> standardAccountCombo;
private boolean discoverAccounts = false;
@@ -53,13 +55,15 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
List<StandardAccount> availableAccounts = new ArrayList<>();
for(StandardAccount standardAccount : StandardAccount.values()) {
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) {
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.isWhirlpoolAccount(standardAccount)) {
availableAccounts.add(standardAccount);
}
}
if(WhirlpoolServices.canWalletMix(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
availableAccounts.add(StandardAccount.WHIRLPOOL_PREMIX);
availableAccounts.add(WHIRLPOOL_PREMIX);
} else if(WhirlpoolServices.canWatchPostmix(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
availableAccounts.add(WHIRLPOOL_POSTMIX);
}
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
@@ -82,10 +86,14 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
return "None Available";
}
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(account)) {
if(account == WHIRLPOOL_PREMIX) {
return "Whirlpool Accounts";
}
if(account == WHIRLPOOL_POSTMIX) {
return "Whirlpool Postmix (No mixing)";
}
return account.getName();
}
@@ -40,7 +40,7 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
if(utxoEntry != null) {
Address address = addressStatus.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false, getTreeTableView()));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
@@ -0,0 +1,367 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class CardImportPane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
private final KeystoreCardImport importer;
private List<ChildNumber> derivation;
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png");
this.importer = importer;
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
}
@Override
protected Control createButton() {
importButton = new Button("Import");
Glyph tapGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
tapGlyph.setFontSize(12);
importButton.setGraphic(tapGlyph);
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importCard();
});
return importButton;
}
private void importCard() {
if(!isReaderAvailable()) {
setError("No reader", "No card reader was detected.");
importButton.setDisable(false);
return;
}
StringProperty messageProperty = new SimpleStringProperty();
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
try {
if(pin.get().length() < importer.getWalletModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? (!importer.getWalletModel().hasDefaultPin() && !importer.isInitialized() ? "Choose a PIN code" : "Enter PIN code") : "PIN code too short");
setContent(getPinAndDerivationEntry());
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
return;
}
if(!importer.isInitialized()) {
setDescription("Card not initialized");
setContent(getInitializationPanel(messageProperty));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
} catch(CardException e) {
setError("Card Error", e.getMessage());
importButton.setDisable(false);
return;
}
CardImportService cardImportService = new CardImportService(importer, pin.get(), derivation, messageProperty);
cardImportService.setOnSucceeded(event -> {
EventManager.get().post(new KeystoreImportEvent(cardImportService.getValue()));
});
cardImportService.setOnFailed(event -> {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinAndDerivationEntry());
} else {
log.error("Error importing keystore from card", event.getSource().getException());
setError("Import Error", rootCause.getMessage());
}
importButton.setDisable(false);
});
cardImportService.start();
}
private Node getInitializationPanel(StringProperty messageProperty) {
if(importer.getWalletModel().requiresSeedInitialization()) {
return getSeedInitializationPanel(messageProperty);
}
return getEntropyInitializationPanel(messageProperty);
}
private Node getSeedInitializationPanel(StringProperty messageProperty) {
VBox confirmationBox = new VBox(5);
CustomPasswordField confirmationPin = new ViewPasswordField();
confirmationPin.setPromptText("Re-enter chosen PIN");
confirmationBox.getChildren().add(confirmationPin);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
if(!pin.get().equals(confirmationPin.getText())) {
setError("PIN Error", "The confirmation PIN did not match");
return;
}
int pinSize = pin.get().length();
if(pinSize < importer.getWalletModel().getMinPinLength() || pinSize > importer.getWalletModel().getMaxPinLength()) {
setError("PIN Error", "PIN length must be between " + importer.getWalletModel().getMinPinLength() + " and " + importer.getWalletModel().getMaxPinLength() + " characters");
return;
}
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(importer.getWalletModel().toDisplayString() + " Seed Words", 12);
seedEntryDialog.initOwner(this.getScene().getWindow());
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
try {
List<String> mnemonicWords = optWords.get();
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
byte[] seedBytes = seed.getSeedBytes();
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), seedBytes, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
setDescription("Leave card on reader");
setExpanded(false);
importButton.setDisable(false);
});
cardInitializationService.setOnFailed(failEvent -> {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
});
cardInitializationService.start();
} catch(MnemonicException e) {
log.error("Invalid seed entered", e);
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
initializeButton.setDisable(false);
}
} else {
initializeButton.setDisable(false);
}
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(confirmationBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
return contentBox;
}
private Node getEntropyInitializationPanel(StringProperty messageProperty) {
VBox initTypeBox = new VBox(5);
RadioButton automatic = new RadioButton("Automatic (Recommended)");
RadioButton advanced = new RadioButton("Advanced");
TextField entropy = new TextField();
entropy.setPromptText("Enter input for user entropy");
entropy.setDisable(true);
ToggleGroup toggleGroup = new ToggleGroup();
automatic.setToggleGroup(toggleGroup);
advanced.setToggleGroup(toggleGroup);
automatic.setSelected(true);
toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
entropy.setDisable(newValue == automatic);
});
initTypeBox.getChildren().addAll(automatic, advanced, entropy);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8));
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), chainCode, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
setDescription("Leave card on reader");
setExpanded(false);
importButton.setDisable(false);
});
cardInitializationService.setOnFailed(failEvent -> {
Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
importButton.setDisable(false);
} else {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
}
});
cardInitializationService.start();
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(initTypeBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(initTypeBox, Priority.ALWAYS);
return contentBox;
}
private Node getPinAndDerivationEntry() {
VBox vBox = new VBox();
vBox.getChildren().add(getPinEntry());
vBox.getChildren().add(getDerivationEntry());
return vBox;
}
private Node getPinEntry() {
VBox vBox = new VBox();
CustomPasswordField pinField = new ViewPasswordField();
pinField.setPromptText("PIN Code");
importButton.setDefaultButton(true);
pin.bind(pinField.textProperty());
HBox.setHgrow(pinField, Priority.ALWAYS);
Platform.runLater(pinField::requestFocus);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 0, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
return vBox;
}
private Node getDerivationEntry() {
VBox vBox = new VBox();
CheckBox checkBox = new CheckBox("Use Custom Derivation");
Label customLabel = new Label("Derivation:");
TextField customDerivation = new TextField(KeyDerivation.writePath(derivation));
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(customDerivation, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
customDerivation.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue.isEmpty() || !KeyDerivation.isValid(newValue)) {
importButton.setDisable(true);
} else {
importButton.setDisable(false);
derivation = KeyDerivation.parsePath(newValue);
}
});
checkBox.managedProperty().bind(checkBox.visibleProperty());
customLabel.managedProperty().bind(customLabel.visibleProperty());
customDerivation.managedProperty().bind(customDerivation.visibleProperty());
customLabel.visibleProperty().bind(checkBox.visibleProperty().not());
customDerivation.visibleProperty().bind(checkBox.visibleProperty().not());
checkBox.selectedProperty().addListener((observable, oldValue, newValue) -> {
checkBox.setVisible(false);
});
HBox derivationBox = new HBox();
derivationBox.setAlignment(Pos.CENTER_LEFT);
derivationBox.setSpacing(20);
derivationBox.getChildren().addAll(checkBox, customLabel, customDerivation);
derivationBox.setPadding(new Insets(10, 30, 10, 30));
derivationBox.setPrefHeight(50);
vBox.getChildren().addAll(derivationBox);
return vBox;
}
public static class CardInitializationService extends Service<Void> {
private final KeystoreCardImport cardImport;
private final String pin;
private final byte[] chainCode;
private final StringProperty messageProperty;
public CardInitializationService(KeystoreCardImport cardImport, String pin, byte[] chainCode, StringProperty messageProperty) {
this.cardImport = cardImport;
this.pin = pin;
this.chainCode = chainCode;
this.messageProperty = messageProperty;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
cardImport.initialize(pin, chainCode, messageProperty);
return null;
}
};
}
}
public static class CardImportService extends Service<Keystore> {
private final KeystoreCardImport cardImport;
private final String pin;
private final List<ChildNumber> derivation;
private final StringProperty messageProperty;
public CardImportService(KeystoreCardImport cardImport, String pin, List<ChildNumber> derivation, StringProperty messageProperty) {
this.cardImport = cardImport;
this.pin = pin;
this.derivation = derivation;
this.messageProperty = messageProperty;
}
@Override
protected Task<Keystore> createTask() {
return new Task<>() {
@Override
protected Keystore call() throws Exception {
return cardImport.getKeystore(pin, derivation, messageProperty);
}
};
}
}
}
@@ -0,0 +1,108 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.scene.control.*;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
private final CustomPasswordField existingPin;
private final CustomPasswordField newPin;
private final CustomPasswordField newPinConfirm;
private final CheckBox backupFirst;
private final ButtonType okButtonType;
public CardPinDialog(WalletModel walletModel, boolean backupOnly) {
this.existingPin = new ViewPasswordField();
this.newPin = new ViewPasswordField();
this.newPinConfirm = new ViewPasswordField();
this.backupFirst = new CheckBox();
if(backupOnly) {
newPin.textProperty().bind(existingPin.textProperty());
newPinConfirm.textProperty().bind(existingPin.textProperty());
}
final DialogPane dialogPane = getDialogPane();
setTitle(backupOnly ? "Backup Card" : "Change Card PIN");
dialogPane.setHeaderText(backupOnly ? "Enter the current card PIN." : "Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(backupOnly ? 135 : 260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
lock.setFontSize(50);
dialogPane.setGraphic(lock);
Form form = new Form();
Fieldset fieldset = new Fieldset();
fieldset.setText("");
fieldset.setSpacing(10);
Field currentField = new Field();
currentField.setText("Current PIN:");
currentField.getInputs().add(existingPin);
Field newField = new Field();
newField.setText("New PIN:");
newField.getInputs().add(newPin);
Field confirmField = new Field();
confirmField.setText("Confirm new PIN:");
confirmField.getInputs().add(newPinConfirm);
Field backupField = new Field();
backupField.setText("Backup First:");
backupField.getInputs().add(backupFirst);
if(backupOnly) {
fieldset.getChildren().addAll(currentField);
} else {
fieldset.getChildren().addAll(currentField, newField, confirmField);
}
if(walletModel.supportsBackup()) {
fieldset.getChildren().add(backupField);
}
form.getChildren().add(fieldset);
dialogPane.setContent(form);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()));
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()));
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
});
okButtonType = new javafx.scene.control.ButtonType(backupOnly ? "Backup" : "Change", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
okButton.setPrefWidth(130);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()
|| newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()
|| !newPin.getText().equals(newPinConfirm.getText()),
existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty());
okButton.disableProperty().bind(isInvalid);
Platform.runLater(existingPin::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new CardPinChange(existingPin.getText(), newPin.getText(), backupFirst.isSelected()) : null);
}
public record CardPinChange(String currentPin, String newPin, boolean backupFirst) { }
}
@@ -2,27 +2,35 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.Region;
import javafx.util.Duration;
import org.controlsfx.tools.Platform;
import java.text.DecimalFormat;
class CoinCell extends TreeTableCell<Entry, Number> {
private final Tooltip tooltip;
class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsListener {
private final CoinTooltip tooltip;
private final CoinContextMenu contextMenu;
private IntegerProperty confirmationsProperty;
public CoinCell() {
super();
tooltip = new Tooltip();
tooltip = new CoinTooltip();
tooltip.setShowDelay(Duration.millis(500));
contextMenu = new CoinContextMenu();
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
getStyleClass().add("number-field");
@@ -37,6 +45,7 @@ class CoinCell extends TreeTableCell<Entry, Number> {
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
} else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
@@ -50,21 +59,18 @@ class CoinCell extends TreeTableCell<Entry, Number> {
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
setText(btcValue);
} else {
tooltip.setText(btcValue + " " + BitcoinUnit.BTC.getLabel());
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
setText(satsValue);
}
setTooltip(tooltip);
String tooltipValue = tooltip.getText();
contextMenu.updateAmount(amount);
setContextMenu(contextMenu);
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
});
tooltip.showConfirmations(transactionEntry.confirmationsProperty());
if(transactionEntry.isConfirming()) {
ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations());
@@ -94,4 +100,97 @@ class CoinCell extends TreeTableCell<Entry, Number> {
}
}
}
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static final class CoinTooltip extends Tooltip {
private final IntegerProperty confirmationsProperty = new SimpleIntegerProperty();
private boolean showConfirmations;
private String value;
public void setValue(String value) {
this.value = value;
setTooltipText();
}
public void showConfirmations(IntegerProperty txEntryConfirmationsProperty) {
showConfirmations = true;
int confirmations = txEntryConfirmationsProperty.get();
if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
confirmationsProperty.bind(txEntryConfirmationsProperty);
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
setTooltipText();
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
confirmationsProperty.unbind();
}
});
} else {
confirmationsProperty.unbind();
confirmationsProperty.set(confirmations);
}
setTooltipText();
}
private void setTooltipText() {
setText(value + (showConfirmations ? " (" + getConfirmationsDescription() + ")" : ""));
}
public String getConfirmationsDescription() {
int confirmations = confirmationsProperty.get();
if(confirmations == 0) {
return "Unconfirmed in mempool";
} else if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
return confirmations + " confirmation" + (confirmations == 1 ? "" : "s");
} else {
return BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM + "+ confirmations";
}
}
}
private static class CoinContextMenu extends ContextMenu {
private Number amount;
public void updateAmount(Number amount) {
if(amount.equals(this.amount)) {
return;
}
this.amount = amount;
getItems().clear();
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
copySatsValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(amount.toString());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyBtcValue = new MenuItem("Copy Value in BTC");
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(amount.longValue()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
}
}
}
@@ -12,7 +12,7 @@ import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
}
public UnitFormat getUnitFormat() {
@@ -29,8 +29,8 @@ public class CoinTextFormatter extends TextFormatter<String> {
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(unitFormat.getLocale()));
this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -17,6 +18,7 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import javafx.scene.layout.StackPane;
@@ -28,6 +30,7 @@ import java.util.Optional;
public class CoinTreeTable extends TreeTableView<Entry> {
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
private CurrencyRate currencyRate;
public BitcoinUnit getBitcoinUnit() {
return bitcoinUnit;
@@ -64,6 +67,18 @@ public class CoinTreeTable extends TreeTableView<Entry> {
}
}
public CurrencyRate getCurrencyRate() {
return currencyRate;
}
public void setCurrencyRate(CurrencyRate currencyRate) {
this.currencyRate = currencyRate;
if(!getChildren().isEmpty()) {
refresh();
}
}
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
if(getRoot() != null) {
Entry entry = getRoot().getValue();
@@ -93,7 +108,8 @@ public class CoinTreeTable extends TreeTableView<Entry> {
Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> {
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate());
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate(), false);
dlg.initOwner(this.getScene().getWindow());
Optional<Date> optDate = dlg.showAndWait();
if(optDate.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
@@ -119,4 +135,12 @@ public class CoinTreeTable extends TreeTableView<Entry> {
stackPane.setAlignment(Pos.CENTER);
return stackPane;
}
public void setSortColumn(int columnIndex, TreeTableColumn.SortType sortType) {
if(columnIndex >= 0 && columnIndex < getColumns().size() && getSortOrder().isEmpty() && !getRoot().getChildren().isEmpty()) {
TreeTableColumn<Entry, ?> column = getColumns().get(columnIndex);
column.setSortType(sortType == null ? TreeTableColumn.SortType.DESCENDING : sortType);
getSortOrder().add(column);
}
}
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.property.IntegerProperty;
public interface ConfirmationsListener {
IntegerProperty getConfirmationsProperty();
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
@@ -12,6 +11,8 @@ import javafx.util.Duration;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import static com.sparrowwallet.sparrow.control.EntryCell.HashIndexEntryContextMenu;
public class DateCell extends TreeTableCell<Entry, Entry> {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
@@ -36,11 +37,11 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")));
setContextMenu(null);
setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), utxoEntry));
} else if(utxoEntry.getHashIndex().getDate() != null) {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
setContextMenu(new DateContextMenu(getTreeTableView(), utxoEntry, date));
} else {
setText("Unknown");
setContextMenu(null);
@@ -56,8 +57,10 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
}
}
private static class DateContextMenu extends ContextMenu {
public DateContextMenu(String date, BlockTransactionHashIndex reference) {
private static class DateContextMenu extends HashIndexEntryContextMenu {
public DateContextMenu(TreeTableView<Entry> treeTableView, UtxoEntry utxoEntry, String date) {
super(treeTableView, utxoEntry);
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -70,7 +73,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
copyHeight.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(reference.getHeight() > 0 ? Integer.toString(reference.getHeight()) : "Mempool");
content.putString(utxoEntry.getHashIndex().getHeight() > 0 ? Integer.toString(utxoEntry.getHashIndex().getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content);
});
@@ -68,7 +68,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
stackPane.getChildren().addAll(anchorPane, scanBox);
List<Device> devices = AppServices.getDevices();
List<Device> devices = getDevices();
if(devices == null || devices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
@@ -91,11 +91,16 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
}
protected List<Device> getDevices() {
return AppServices.getDevices();
}
private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
@@ -9,11 +9,11 @@ import com.sparrowwallet.sparrow.io.Device;
import java.util.stream.Collectors;
public class DeviceAddressDialog extends DeviceDialog<String> {
public class DeviceDisplayAddressDialog extends DeviceDialog<String> {
private final Wallet wallet;
private final OutputDescriptor outputDescriptor;
public DeviceAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
public DeviceDisplayAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
super(outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList()));
this.wallet = wallet;
this.outputDescriptor = outputDescriptor;
@@ -0,0 +1,29 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.DeviceAddressEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceGetAddressDialog extends DeviceDialog<Address> {
public DeviceGetAddressDialog(List<String> operationFingerprints) {
super(operationFingerprints);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(DevicePane.DeviceOperation.GET_ADDRESS, device, defaultDevice);
}
@Subscribe
public void deviceAddress(DeviceAddressEvent event) {
setResult(event.getAddress());
}
}
@@ -1,25 +1,33 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -34,8 +42,10 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class DevicePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
@@ -58,8 +68,12 @@ public class DevicePane extends TitledDescriptionPane {
private Button displayAddressButton;
private Button signMessageButton;
private Button discoverKeystoresButton;
private ButtonBase getPrivateKeyButton;
private Button getAddressButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
private final SimpleStringProperty pin = new SimpleStringProperty("");
private final StringProperty messageProperty = new SimpleStringProperty("");
private boolean defaultDevice;
@@ -83,13 +97,17 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, importButton);
}
public DevicePane(PSBT psbt, Device device, boolean defaultDevice) {
public DevicePane(Wallet wallet, PSBT psbt, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null;
this.wallet = wallet;
this.psbt = psbt;
this.outputDescriptor = null;
this.keyDerivation = null;
@@ -106,6 +124,10 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
}
@@ -152,6 +174,10 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
@@ -178,6 +204,41 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton);
}
public DevicePane(DeviceOperation deviceOperation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = deviceOperation;
this.wallet = null;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
this.availableAccounts = null;
setDefaultStatus();
showHideLink.setVisible(false);
ButtonBase button;
if(deviceOperation == DeviceOperation.GET_PRIVATE_KEY) {
createGetPrivateKeyButton();
button = getPrivateKeyButton;
} else if(deviceOperation == DeviceOperation.GET_ADDRESS) {
createGetAddressButton();
button = getAddressButton;
} else {
throw new UnsupportedOperationException("Cannot construct device pane for operation " + deviceOperation);
}
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().add(button);
}
private void initialise(Device device) {
if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice);
@@ -201,7 +262,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private void setDefaultStatus() {
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : device.isCard() ? "Leave card on reader" : "Unlocked");
}
private void createUnlockButton() {
@@ -239,7 +300,7 @@ public class DevicePane extends TitledDescriptionPane {
if(importButton instanceof SplitMenuButton importMenuButton) {
if(wallet.getScriptType() == null) {
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH};
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH, ScriptType.P2TR};
for(ScriptType scriptType : scriptTypes) {
MenuItem item = new MenuItem(scriptType.getDescription());
final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
@@ -321,6 +382,60 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setVisible(false);
}
private void createGetPrivateKeyButton() {
int currentSlot = 0;
boolean initialized = true;
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), null);
currentSlot = cardApi.getCurrentSlot();
initialized = cardApi.isInitialized();
} catch(Exception e) {
//ignore
}
getPrivateKeyButton = currentSlot > 0 ? new SplitMenuButton() : new Button();
getPrivateKeyButton.setAlignment(Pos.CENTER_RIGHT);
getPrivateKeyButton.setText("Get Private Key");
getPrivateKeyButton.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(null);
});
if(getPrivateKeyButton instanceof SplitMenuButton getPrivateKeyMenuButton) {
int[] previousSlots = IntStream.range(0, currentSlot).toArray();
for(int previousSlot : previousSlots) {
MenuItem previousSlotItem = new MenuItem("Slot #" + previousSlot);
previousSlotItem.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(previousSlot);
});
getPrivateKeyMenuButton.getItems().add(previousSlotItem);
}
if(initialized) {
int finalSlot = currentSlot;
MenuItem currentSlotItem = new MenuItem("Current Slot");
currentSlotItem.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(finalSlot);
});
getPrivateKeyMenuButton.getItems().add(currentSlotItem);
}
}
getPrivateKeyButton.managedProperty().bind(getPrivateKeyButton.visibleProperty());
getPrivateKeyButton.setVisible(false);
}
private void createGetAddressButton() {
getAddressButton = new Button("Get Address");
getAddressButton.setAlignment(Pos.CENTER_RIGHT);
getAddressButton.setOnAction(event -> {
getAddressButton.setDisable(true);
getAddress();
});
getAddressButton.managedProperty().bind(getAddressButton.visibleProperty());
getAddressButton.setVisible(false);
}
private void unlock(Device device) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
@@ -554,7 +669,36 @@ public class DevicePane extends TitledDescriptionPane {
}
private void importKeystore(List<ChildNumber> derivation) {
if(device.getFingerprint() == null) {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? (device.getModel().hasDefaultPin() ? "Enter PIN code" : "Choose a PIN code") : "PIN code too short");
setContent(getCardPinEntry(importButton));
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
return;
}
setDescription("Card not initialized");
setContent(getCardInitializationPanel(cardApi, importButton, DeviceOperation.IMPORT));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
Service<Keystore> importService = cardApi.getImportService(derivation, messageProperty);
handleCardOperation(importService, importButton, "Import", true, event -> {
importKeystore(derivation, importService.getValue());
});
} catch(Exception e) {
log.error("Import Error: " + e.getMessage(), e);
setError("Import Error", e.getMessage());
importButton.setDisable(false);
}
} else if(device.getFingerprint() == null) {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get());
enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue();
@@ -591,18 +735,7 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
if(wallet.getScriptType() == null) {
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
importKeystore(derivation, keystore);
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
@@ -616,20 +749,74 @@ public class DevicePane extends TitledDescriptionPane {
getXpubService.start();
}
private void importKeystore(List<ChildNumber> derivation, Keystore keystore) {
if(wallet.getScriptType() == null) {
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
}
private void sign() {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt);
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<PSBT> signService = cardApi.getSignService(wallet, psbt, messageProperty);
handleCardOperation(signService, signButton, "Signing", true, event -> {
EventManager.get().post(new PSBTSignedEvent(psbt, signService.getValue()));
});
} catch(Exception e) {
log.error("Signing Error: " + e.getMessage(), e);
setError("Signing Error", e.getMessage());
signButton.setDisable(false);
}
} else {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt);
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
showHideLink.setVisible(false);
signPSBTService.start();
}
}
private void handleCardOperation(Service<?> service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler<WorkerStateEvent> successHandler) {
if(pinRequired && pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(operationButton));
showHideLink.setVisible(false);
setExpanded(true);
operationButton.setDisable(false);
return;
}
service.setOnSucceeded(successHandler);
service.setOnFailed(event -> {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getCardPinEntry(operationButton));
} else {
log.error(operationDescription + " Error: " + rootCause.getMessage(), event.getSource().getException());
setError(operationDescription + " Error", rootCause.getMessage());
}
operationButton.setDisable(false);
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
showHideLink.setVisible(false);
signPSBTService.start();
service.start();
}
private void displayAddress() {
@@ -647,17 +834,32 @@ public class DevicePane extends TitledDescriptionPane {
}
private void signMessage() {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty);
handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
} catch(Exception e) {
log.error("Signing Error: " + e.getMessage(), e);
setError("Signing Error", e.getMessage());
signButton.setDisable(false);
}
} else {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
}
}
private void discoverKeystores() {
@@ -717,6 +919,55 @@ public class DevicePane extends TitledDescriptionPane {
getXpubsService.start();
}
private void getPrivateKey(Integer slot) {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<ECKey> privateKeyService = cardApi.getPrivateKeyService(slot, messageProperty);
handleCardOperation(privateKeyService, getPrivateKeyButton, "Private Key", true, event -> {
EventManager.get().post(new DeviceGetPrivateKeyEvent(privateKeyService.getValue(), cardApi.getDefaultScriptType()));
});
} catch(Exception e) {
log.error("Private Key Error: " + e.getMessage(), e);
setError("Private Key Error", e.getMessage());
getPrivateKeyButton.setDisable(false);
}
}
}
private void getAddress() {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(getAddressButton));
showHideLink.setVisible(false);
setExpanded(true);
getAddressButton.setDisable(false);
return;
}
setDescription("Card not initialized");
setContent(getCardInitializationPanel(cardApi, getAddressButton, DeviceOperation.GET_ADDRESS));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
Service<Address> addressService = cardApi.getAddressService(messageProperty);
handleCardOperation(addressService, getAddressButton, "Address", false, event -> {
EventManager.get().post(new DeviceAddressEvent(addressService.getValue()));
});
} catch(Exception e) {
log.error("Address Error: " + e.getMessage(), e);
setError("Address Error", e.getMessage());
getAddressButton.setDisable(false);
}
}
}
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
@@ -724,7 +975,7 @@ public class DevicePane extends TitledDescriptionPane {
}
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(true);
showHideLink.setVisible(!device.isCard());
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
@@ -743,6 +994,16 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setDefaultButton(defaultDevice);
discoverKeystoresButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_PRIVATE_KEY)) {
if(defaultDevice) {
getPrivateKeyButton.getStyleClass().add("default-button");
}
getPrivateKeyButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_ADDRESS)) {
getAddressButton.setDefaultButton(defaultDevice);
getAddressButton.setVisible(true);
showHideLink.setVisible(false);
}
}
@@ -750,7 +1011,7 @@ public class DevicePane extends TitledDescriptionPane {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
derivationField.setDisable(keyDerivation != null);
derivationField.setDisable(device.isCard() || keyDerivation != null);
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
@@ -785,11 +1046,161 @@ public class DevicePane extends TitledDescriptionPane {
return contentBox;
}
private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
if(device.getModel().requiresSeedInitialization()) {
return getCardSeedInitializationPanel(cardApi, operationButton, deviceOperation);
}
return getCardEntropyInitializationPanel(cardApi, operationButton, deviceOperation);
}
private Node getCardSeedInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
VBox confirmationBox = new VBox(5);
CustomPasswordField confirmationPin = new ViewPasswordField();
confirmationPin.setPromptText("Re-enter chosen PIN");
confirmationBox.getChildren().add(confirmationPin);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
if(!pin.get().equals(confirmationPin.getText())) {
setError("PIN Error", "The confirmation PIN did not match");
return;
}
int pinSize = pin.get().length();
if(pinSize < device.getModel().getMinPinLength() || pinSize > device.getModel().getMaxPinLength()) {
setError("PIN Error", "PIN length must be between " + device.getModel().getMinPinLength() + " and " + device.getModel().getMaxPinLength() + " characters");
return;
}
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(device.getModel().toDisplayString() + " Seed Words", 12);
seedEntryDialog.initOwner(this.getScene().getWindow());
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
try {
List<String> mnemonicWords = optWords.get();
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
byte[] seedBytes = seed.getSeedBytes();
Service<Void> cardInitializationService = cardApi.getInitializationService(seedBytes, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
operationButton.setDisable(false);
setDefaultStatus();
setExpanded(false);
});
cardInitializationService.setOnFailed(failEvent -> {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
});
cardInitializationService.start();
} catch(MnemonicException e) {
log.error("Invalid seed entered", e);
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
initializeButton.setDisable(false);
}
} else {
initializeButton.setDisable(false);
}
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(confirmationBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
return contentBox;
}
private Node getCardEntropyInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
VBox initTypeBox = new VBox(5);
RadioButton automatic = new RadioButton("Automatic (Recommended)");
RadioButton advanced = new RadioButton("Advanced");
TextField entropy = new TextField();
entropy.setPromptText("Enter input for user entropy");
entropy.setDisable(true);
ToggleGroup toggleGroup = new ToggleGroup();
automatic.setToggleGroup(toggleGroup);
advanced.setToggleGroup(toggleGroup);
automatic.setSelected(true);
toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
entropy.setDisable(newValue == automatic);
});
initTypeBox.getChildren().addAll(automatic, advanced, entropy);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8));
Service<Void> cardInitializationService = cardApi.getInitializationService(chainCode, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
if(deviceOperation == DeviceOperation.IMPORT) {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
} else if(deviceOperation == DeviceOperation.GET_ADDRESS) {
AppServices.showSuccessDialog("Card Reinitialized", "The card was successfully reinitialized.\n\nYou can now retrieve the new deposit address.");
}
operationButton.setDisable(false);
setDefaultStatus();
setExpanded(false);
});
cardInitializationService.setOnFailed(failEvent -> {
Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getCardPinEntry(operationButton));
operationButton.setDisable(false);
} else {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
}
});
cardInitializationService.start();
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(initTypeBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(initTypeBox, Priority.ALWAYS);
return contentBox;
}
private Node getCardPinEntry(ButtonBase operationButton) {
VBox vBox = new VBox();
CustomPasswordField pinField = new ViewPasswordField();
pinField.setPromptText("PIN Code");
if(operationButton instanceof Button defaultButton) {
defaultButton.setDefaultButton(true);
}
pin.bind(pinField.textProperty());
HBox.setHgrow(pinField, Priority.ALWAYS);
Platform.runLater(pinField::requestFocus);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
return vBox;
}
public Device getDevice() {
return device;
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES;
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, GET_PRIVATE_KEY, GET_ADDRESS;
}
}
@@ -2,17 +2,24 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class DeviceSignDialog extends DeviceDialog<PSBT> {
private static final Logger log = LoggerFactory.getLogger(DeviceSignDialog.class);
private final Wallet wallet;
private final PSBT psbt;
public DeviceSignDialog(List<String> operationFingerprints, PSBT psbt) {
public DeviceSignDialog(Wallet wallet, List<String> operationFingerprints, PSBT psbt) {
super(operationFingerprints);
this.wallet = wallet;
this.psbt = psbt;
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -23,7 +30,7 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(psbt, device, defaultDevice);
return new DevicePane(wallet, psbt, device, defaultDevice);
}
@Subscribe
@@ -0,0 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.DeviceGetPrivateKeyEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceUnsealDialog extends DeviceDialog<DeviceUnsealDialog.DevicePrivateKey> {
public DeviceUnsealDialog(List<String> operationFingerprints) {
super(operationFingerprints);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(DevicePane.DeviceOperation.GET_PRIVATE_KEY, device, defaultDevice);
}
@Subscribe
public void deviceGetPrivateKey(DeviceGetPrivateKeyEvent event) {
setResult(new DevicePrivateKey(event.getPrivateKey(), event.getScriptType()));
}
public record DevicePrivateKey(ECKey privateKey, ScriptType scriptType) {}
}
@@ -12,6 +12,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
@@ -30,14 +32,16 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class EntryCell extends TreeTableCell<Entry, Entry> {
public class EntryCell extends TreeTableCell<Entry, Entry> implements ConfirmationsListener {
private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*)\\(Replaced By Fee( #)?(\\d+)?\\).*");
public static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*?)( \\(Replaced By Fee( #)?(\\d+)?\\)).*?");
private static EntryCell lastCell;
private IntegerProperty confirmationsProperty;
public EntryCell() {
super();
setAlignment(Pos.CENTER_LEFT);
@@ -96,7 +100,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() &&
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
@@ -120,7 +124,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(nodeEntry.getNode().toString());
@@ -145,6 +149,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(getTreeTableView().getScene().getWindow());
messageSignDialog.showAndWait();
});
actionBox.getChildren().add(signMessageButton);
@@ -189,6 +194,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
}
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
@@ -197,7 +217,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(TransactionInput::isReplaceByFeeEnabled)
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled())
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
@@ -211,31 +231,47 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE)
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
double vSize = tx.getVirtualSize();
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
//Remove any UTXOs created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
//If there is insufficient change output, include another random UTXO so the fee can be increased
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
utxos.add(utxo);
changeTotal += utxo.getValue();
vSize += inputSize;
if(changeTotal == 0) {
//Add change output length to vSize if change was not present on the original transaction
TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getOutputScript());
vSize += changeOutput.getLength();
}
double inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? (double)tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(utxos), new SpentTxoFilter(blockTransaction.getHash()), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction) {
//If there is insufficient change output, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
utxos.add(utxo);
changeTotal += utxo.getValue();
vSize += inputSize;
}
}
Long fee = blockTransaction.getFee();
if(fee != null) {
//Replacement tx fees must be greater than the original tx fees by its minimum relay cost
fee += (long)Math.ceil(vSize * AppServices.getMinimumRelayFeeRate());
}
Long rbfFee = fee;
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
externalOutputs.removeAll(ourOutputs);
@@ -244,17 +280,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(label);
label = REPLACED_BY_FEE_SUFFIX.matcher(label).replaceAll("$1");
String[] paymentLabels = label.split(", ");
if(externalOutputs.size() > 1 && externalOutputs.size() == paymentLabels.length) {
label = paymentLabels[externalOutputs.indexOf(txOutput)];
}
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel());
if(matcher.matches()) {
String base = matcher.group(1);
if(matcher.groupCount() > 2 && matcher.group(3) != null) {
int count = Integer.parseInt(matcher.group(3)) + 1;
label = base + "(Replaced By Fee #" + count + ")";
if(matcher.groupCount() > 3 && matcher.group(4) != null) {
int count = Integer.parseInt(matcher.group(4)) + 1;
label += " (Replaced By Fee #" + count + ")";
} else {
label = base + "(Replaced By Fee #2)";
label += " (Replaced By Fee #2)";
}
} else {
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
label += " (Replaced By Fee)";
}
if(txOutput.getScript().getToAddress() != null) {
@@ -296,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction)));
}
private static Double getMaxFeeRate() {
@@ -312,30 +352,57 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
.map(HashIndexEntry::getHashIndex)
.collect(Collectors.toList());
if(ourOutputs.isEmpty()) {
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend");
AppServices.showErrorDialog("No spendable outputs", "None of the outputs on this transaction are spendable.\n\n Ensure that the outputs are not frozen" +
(transactionEntry.getConfirmations() <= 0 ? ", and spending unconfirmed UTXOs is allowed." : "."));
return;
}
BlockTransactionHashIndex utxo = ourOutputs.get(0);
BlockTransactionHashIndex cpfpUtxo = ourOutputs.get(0);
Address freshAddress = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress();
TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), freshAddress.getOutputScript());
long dustThreshold = freshAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
double inputSize = freshAddress.getScriptType().getInputVbytes();
double vSize = inputSize + txOutput.getLength();
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(List.of(cpfpUtxo)), new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
List<BlockTransactionHashIndex> utxos = new ArrayList<>();
utxos.add(cpfpUtxo);
long inputTotal = cpfpUtxo.getValue();
while((inputTotal - (long)(getMaxFeeRate() * vSize)) < dustThreshold && !outputGroups.isEmpty()) {
//If there is insufficient input value, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
utxos.add(utxo);
inputTotal += utxo.getValue();
vSize += inputSize;
}
}
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true);
Payment payment = new Payment(freshAddress, label, inputTotal, true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false)));
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
}
private static boolean canRBF(BlockTransaction blockTransaction) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
}
private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet();
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB) &&
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
@@ -408,7 +475,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
}
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled");
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled");
}
return tooltip;
@@ -485,7 +552,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
@@ -496,7 +563,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(increaseFee);
}
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> {
@@ -518,6 +585,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(createCpfp);
}
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
hide();
@@ -539,6 +613,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -563,12 +643,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(viewTransaction, copyDate, copyTxid, copyHeight);
getItems().addAll(viewTransaction, openBlockExplorer, copyDate, copyTxid, copyHeight);
}
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems, TreeTableView<Entry> treetable) {
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
@@ -586,12 +666,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(treetable.getScene().getWindow());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
if(nodeEntry != null && !nodeEntry.getNode().getUnspentTransactionOutputs().isEmpty()) {
if(addUtxoItems && 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());
@@ -635,14 +716,6 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
copyHex.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(Utils.bytesToHex(address.getOutputScriptData()));
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
copyOutputDescriptor.setOnAction(AE -> {
hide();
@@ -651,11 +724,23 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
getItems().addAll(copyAddress, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
copyHex.setOnAction(AE -> {
hide();
Script outputScript = nodeEntry.getWallet().getOutputScript(nodeEntry.getNode());
ClipboardContent content = new ClipboardContent();
content.putString(Utils.bytesToHex(outputScript.getProgram()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyHex);
}
}
}
private static class HashIndexEntryContextMenu extends ContextMenu {
static class HashIndexEntryContextMenu extends ContextMenu {
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -711,6 +796,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
cell.getStyleClass().remove("transaction-row");
cell.getStyleClass().remove("node-row");
cell.getStyleClass().remove("utxo-row");
cell.getStyleClass().remove("unconfirmed-row");
cell.getStyleClass().remove("summary-row");
cell.getStyleClass().remove("address-cell");
cell.getStyleClass().remove("hashindex-row");
cell.getStyleClass().remove("confirming");
@@ -722,13 +809,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(entry instanceof TransactionEntry) {
cell.getStyleClass().add("transaction-row");
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
if(!transactionEntry.isConfirming()) {
cell.getStyleClass().remove("confirming");
}
});
if(cell instanceof ConfirmationsListener confirmationsListener) {
if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming");
confirmationsListener.getConfirmationsProperty().bind(transactionEntry.confirmationsProperty());
} else {
confirmationsListener.getConfirmationsProperty().unbind();
}
}
} else if(entry instanceof NodeEntry) {
cell.getStyleClass().add("node-row");
@@ -744,6 +832,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(hashIndexEntry.isSpent()) {
cell.getStyleClass().add("spent");
}
} else if(entry instanceof WalletSummaryDialog.UnconfirmedEntry) {
cell.getStyleClass().add("unconfirmed-row");
} else if(entry instanceof WalletSummaryDialog.SummaryEntry) {
cell.getStyleClass().add("summary-row");
}
}
}
@@ -0,0 +1,132 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Slider;
import javafx.util.StringConverter;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
public class FeeRangeSlider extends Slider {
public FeeRangeSlider() {
super(0, FEE_RATES_RANGE.size() - 1, 0);
setMajorTickUnit(1);
setMinorTickCount(0);
setSnapToTicks(false);
setShowTickLabels(true);
setShowTickMarks(true);
setBlockIncrement(Math.log(1.02) / Math.log(2));
setLabelFormatter(new StringConverter<>() {
@Override
public String toString(Double object) {
Long feeRate = LONG_FEE_RATES_RANGE.get(object.intValue());
if(isLongFeeRange() && feeRate >= 1000) {
return feeRate / 1000 + "k";
}
return Long.toString(feeRate);
}
@Override
public Double fromString(String string) {
return null;
}
});
updateTrackHighlight();
valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
updateMaxFeeRange(newValue.doubleValue());
}
});
}
public double getFeeRate() {
return Math.pow(2.0, getValue());
}
public void setFeeRate(double feeRate) {
double value = Math.log(feeRate) / Math.log(2);
updateMaxFeeRange(value);
setValue(value);
}
private void updateMaxFeeRange(double value) {
if(value >= getMax() && !isLongFeeRange()) {
setMax(LONG_FEE_RATES_RANGE.size() - 1);
updateTrackHighlight();
} else if(value == getMin() && isLongFeeRange()) {
setMax(FEE_RATES_RANGE.size() - 1);
updateTrackHighlight();
}
}
private boolean isLongFeeRange() {
return getMax() > FEE_RATES_RANGE.size() - 1;
}
public void updateTrackHighlight() {
addFeeRangeTrackHighlight(0);
}
private void addFeeRangeTrackHighlight(int count) {
Platform.runLater(() -> {
Node track = lookup(".track");
if(track != null) {
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
String highlight = "";
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
}
highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";
track.setStyle("-fx-background-color: " +
"-fx-shadow-highlight-color, " +
"linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), " +
"linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)), " +
"linear-gradient(to right, " + highlight + ")");
} else if(count < 20) {
addFeeRangeTrackHighlight(count+1);
}
});
}
private Map<Integer, Double> getTargetBlocksFeeRates() {
Map<Integer, Double> retrievedFeeRates = AppServices.getTargetBlockFeeRates();
if(retrievedFeeRates == null) {
retrievedFeeRates = TARGET_BLOCKS_RANGE.stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> getFallbackFeeRate(),
(u, v) -> { throw new IllegalStateException("Duplicate target blocks"); },
LinkedHashMap::new));
}
return retrievedFeeRates;
}
private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
Collections.reverse(rates);
for(Integer targetBlocks : rates) {
if(targetBlocks < minTargetBlocks) {
return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
}
}
return 100;
}
private int getPercentageOfFeeRange(Double feeRate) {
double index = Math.log(feeRate) / Math.log(2);
if(isLongFeeRange()) {
index *= ((double)FEE_RATES_RANGE.size() / (LONG_FEE_RATES_RANGE.size())) * 0.99;
}
return (int)Math.round(index * 10.0);
}
}
@@ -0,0 +1,94 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import org.controlsfx.tools.Platform;
import java.math.BigDecimal;
import java.util.Currency;
public class FiatCell extends TreeTableCell<Entry, Number> {
private final Tooltip tooltip;
private final FiatContextMenu contextMenu;
public FiatCell() {
super();
tooltip = new Tooltip();
contextMenu = new FiatContextMenu();
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
getStyleClass().add("number-field");
}
}
@Override
protected void updateItem(Number amount, boolean empty) {
super.updateItem(amount, empty);
if(empty || amount == null) {
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
} else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
CoinTreeTable coinTreeTable = (CoinTreeTable) getTreeTableView();
UnitFormat format = coinTreeTable.getUnitFormat();
CurrencyRate currencyRate = coinTreeTable.getCurrencyRate();
if(currencyRate != null && currencyRate.isAvailable()) {
Currency currency = currencyRate.getCurrency();
double btcRate = currencyRate.getBtcRate();
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
setText(label);
setGraphic(null);
setTooltip(tooltip);
setContextMenu(contextMenu);
} else {
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
}
}
}
private class FiatContextMenu extends ContextMenu {
public FiatContextMenu() {
MenuItem copyValue = new MenuItem("Copy Value");
copyValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(getText());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyRate = new MenuItem("Copy Rate");
copyRate.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(getTooltip().getText());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyValue, copyRate);
}
}
}
@@ -150,6 +150,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private void importQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(this.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -162,8 +163,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
setError("Import Error", e.getMessage());
}
} else if(result.outputDescriptor != null) {
wallets = List.of(result.outputDescriptor.toKeystoreWallet(null));
try {
wallets = List.of(result.outputDescriptor.toWallet());
importFile(importer.getName(), null, null);
} catch(ImportException e) {
log.error("Error importing QR", e);
@@ -193,6 +194,10 @@ public abstract class FileImportPane extends TitledDescriptionPane {
}
}
protected List<Wallet> getScannedWallets() {
return wallets;
}
protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException {
if(wallets != null) {
for(Wallet wallet : wallets) {
@@ -215,7 +220,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private Node getPasswordEntry(File file) {
CustomPasswordField passwordField = new ViewPasswordField();
passwordField.setPromptText("Wallet password");
passwordField.setPromptText("Password");
password.bind(passwordField.textProperty());
HBox.setHgrow(passwordField, Priority.ALWAYS);
@@ -0,0 +1,171 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Control;
import javafx.scene.control.ToggleButton;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
public class FileKeystoreExportPane extends TitledDescriptionPane {
private final Keystore keystore;
private final KeystoreFileExport exporter;
private final boolean scannable;
private final boolean file;
public FileKeystoreExportPane(Keystore keystore, KeystoreFileExport exporter) {
super(exporter.getName(), "Keystore export", exporter.getKeystoreExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
this.keystore = keystore;
this.exporter = exporter;
this.scannable = exporter.isKeystoreExportScannable();
this.file = exporter.isKeystoreExportFile();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
}
@Override
protected Control createButton() {
if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
showButton.setSelected(false);
exportQR();
});
ToggleButton fileButton = new ToggleButton("Export File...");
fileButton.setAlignment(Pos.CENTER_RIGHT);
fileButton.setOnAction(event -> {
fileButton.setSelected(false);
exportFile();
});
SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton;
} else if(scannable) {
Button showButton = new Button("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
exportQR();
});
return showButton;
} else {
Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
exportButton.setOnAction(event -> {
exportFile();
});
return exportButton;
}
}
private void exportQR() {
exportKeystore(null, keystore);
}
private void exportFile() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(keystore);
String fileName = keystore.getLabel();
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
exportKeystore(file, keystore);
}
}
private void exportKeystore(File file, Keystore exportKeystore) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
exporter.exportKeystore(exportKeystore, baos);
if(exporter.requiresSignature()) {
String message = baos.toString(StandardCharsets.UTF_8);
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getWalletModel().isCard()) {
TextAreaDialog dialog = new TextAreaDialog(message, false);
dialog.initOwner(this.getScene().getWindow());
dialog.setTitle("Sign " + exporter.getName() + " Export");
dialog.getDialogPane().setHeaderText("The following text needs to be signed by the device.\nClick OK to continue.");
dialog.showAndWait();
Wallet wallet = new Wallet();
wallet.setScriptType(ScriptType.P2PKH);
wallet.getKeystores().add(keystore);
List<String> operationFingerprints = List.of(keystore.getKeyDerivation().getMasterFingerprint());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(operationFingerprints, wallet, message, keystore.getKeyDerivation());
deviceSignMessageDialog.initOwner(this.getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
exporter.addSignature(keystore, optSignature.get(), baos);
}
} else if(keystore.getSource() == KeystoreSource.SW_SEED) {
String signature = keystore.getExtendedPrivateKey().getKey().signMessage(message, ScriptType.P2PKH);
exporter.addSignature(keystore, signature, baos);
} else {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Cannot sign export",
"Signing the " + exporter.getName() + " export with " + keystore.getWalletModel().toDisplayString() + " is not supported." +
"Proceed without signing?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.NO) {
throw new RuntimeException("Export aborted due to lack of device message signing support.");
}
}
}
if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(baos.toByteArray());
EventManager.get().post(new KeystoreExportEvent(exportKeystore));
}
} else {
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof Bip129) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), baos.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
}
}
}
@@ -34,17 +34,4 @@ 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;
}
}
@@ -1,7 +1,10 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.registry.CryptoOutput;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -10,6 +13,8 @@ import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
@@ -24,16 +29,20 @@ import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Optional;
import static com.sparrowwallet.sparrow.wallet.SettingsController.getCryptoOutput;
public class FileWalletExportPane extends TitledDescriptionPane {
private final Wallet wallet;
private final WalletExport exporter;
private final boolean scannable;
private final boolean file;
public FileWalletExportPane(Wallet wallet, WalletExport exporter) {
super(exporter.getName(), "Wallet file export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
super(exporter.getName(), "Wallet export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.exporter = exporter;
this.scannable = exporter.isWalletExportScannable();
this.file = exporter.isWalletExportFile();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
@@ -41,7 +50,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
@Override
protected Control createButton() {
if(scannable) {
if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
@@ -61,6 +70,15 @@ public class FileWalletExportPane extends TitledDescriptionPane {
SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton;
} else if(scannable) {
Button showButton = new Button("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
exportQR();
});
return showButton;
} else {
Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
@@ -81,9 +99,11 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(wallet);
String fileName = wallet.getFullName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
String walletModel = exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
String postfix = walletModel.equals(extension) ? "" : "-" + walletModel;
String fileName = wallet.getFullName() + postfix;
if(exporter.exportsAllWallets()) {
fileName = wallet.getMasterName();
fileName = wallet.getMasterName() + postfix;
}
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
@@ -98,6 +118,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(buttonBox.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
@@ -127,21 +148,35 @@ public class FileWalletExportPane extends TitledDescriptionPane {
private void exportWallet(File file, Wallet exportWallet) {
try {
if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(exportWallet, outputStream);
FileWalletExportService fileWalletExportService = new FileWalletExportService(exporter, file, exportWallet);
fileWalletExportService.setOnSucceeded(event -> {
EventManager.get().post(new WalletExportEvent(exportWallet));
}
});
fileWalletExportService.setOnFailed(event -> {
Throwable e = event.getSource().getException();
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
});
fileWalletExportService.start();
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(exportWallet, outputStream);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig) {
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig || exporter instanceof Bip129) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else if(exporter instanceof Descriptor) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR());
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
@@ -152,4 +187,30 @@ public class FileWalletExportPane extends TitledDescriptionPane {
setError("Export Error", errorMessage);
}
}
public static class FileWalletExportService extends Service<Void> {
private final WalletExport exporter;
private final File file;
private final Wallet wallet;
public FileWalletExportService(WalletExport exporter, File file, Wallet wallet) {
this.exporter = exporter;
this.file = file;
this.wallet = wallet;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(wallet, outputStream);
}
return null;
}
};
}
}
}
@@ -12,13 +12,19 @@ public class FileWalletImportPane extends FileImportPane {
private final WalletImport importer;
public FileWalletImportPane(WalletImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), true);
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), importer.isWalletImportFileFormatAvailable());
this.importer = importer;
}
@Override
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
Wallet wallet = importer.importWallet(inputStream, password);
Wallet wallet;
if(getScannedWallets() != null && !getScannedWallets().isEmpty()) {
wallet = getScannedWallets().iterator().next();
} else {
wallet = importer.importWallet(inputStream, password);
}
if(wallet.getName() == null) {
wallet.setName(fileName);
}
@@ -0,0 +1,66 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.io.*;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Comparator;
import java.util.List;
public class KeystoreExportDialog extends Dialog<Keystore> {
public KeystoreExportDialog(Keystore keystore) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
stackPane.getChildren().add(anchorPane);
ScrollPane scrollPane = new ScrollPane();
scrollPane.setPrefHeight(200);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
List<KeystoreFileExport> exporters = List.of(new Bip129());
Accordion exportAccordion = new Accordion();
for(KeystoreFileExport exporter : exporters) {
if(!exporter.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileKeystoreExportPane exportPane = new FileKeystoreExportPane(keystore, exporter);
exportAccordion.getPanes().add(exportPane);
}
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(exportAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(280);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? keystore : null);
}
@Subscribe
public void keystoreExported(KeystoreExportEvent event) {
setResult(event.getKeystore());
}
}
@@ -1,20 +1,23 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
public class KeystorePassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase;
private final ObjectProperty<byte[]> masterFingerprint = new SimpleObjectProperty<>();
public KeystorePassphraseDialog(Keystore keystore) {
this(null, keystore);
@@ -45,10 +48,38 @@ public class KeystorePassphraseDialog extends Dialog<String> {
content.setPrefHeight(50);
content.getChildren().add(passphrase);
passphrase.textProperty().addListener((observable, oldValue, passphrase) -> {
masterFingerprint.set(getMasterFingerprint(keystore, passphrase));
});
HBox fingerprintBox = new HBox(10);
fingerprintBox.setAlignment(Pos.CENTER_LEFT);
Label fingerprintLabel = new Label("Master fingerprint:");
TextField fingerprintHex = new TextField();
fingerprintHex.setDisable(true);
fingerprintHex.setMaxWidth(80);
fingerprintHex.getStyleClass().addAll("fixed-width");
fingerprintHex.setStyle("-fx-opacity: 0.6");
masterFingerprint.addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
fingerprintHex.setText(Utils.bytesToHex(newValue));
}
});
LifeHashIcon lifeHashIcon = new LifeHashIcon();
lifeHashIcon.dataProperty().bind(masterFingerprint);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("All passphrases create valid wallets." +
"\nThe master fingerprint identifies the keystore and changes as the passphrase changes." +
"\n" + (confirm ? "Take a moment to identify it before proceeding." : "Make sure you recognise it before proceeding."));
fingerprintBox.getChildren().addAll(fingerprintLabel, fingerprintHex, lifeHashIcon, helpLabel);
content.getChildren().add(fingerprintBox);
masterFingerprint.set(getMasterFingerprint(keystore, ""));
Glyph warnGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
warnGlyph.getStyleClass().add("warn-icon");
warnGlyph.setFontSize(12);
Label warnLabel = new Label("A BIP39 passphrase is not a wallet password!", warnGlyph);
Label warnLabel = new Label((confirm ? "Note" : "Check") + " the master fingerprint before proceeding!", warnGlyph);
warnLabel.setGraphicTextGap(5);
content.getChildren().add(warnLabel);
@@ -57,4 +88,14 @@ public class KeystorePassphraseDialog extends Dialog<String> {
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? passphrase.getText() : null);
}
private byte[] getMasterFingerprint(Keystore keystore, String passphrase) {
try {
Keystore copyKeystore = keystore.copy();
copyKeystore.getSeed().setPassphrase(passphrase);
return copyKeystore.getExtendedMasterPrivateKey().getKey().getFingerprint();
} catch(MnemonicException e) {
throw new RuntimeException(e);
}
}
}
@@ -1,6 +1,10 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.event.Event;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTreeTableCell;
@@ -13,9 +17,11 @@ import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
class LabelCell extends TextFieldTreeTableCell<Entry, String> {
class LabelCell extends TextFieldTreeTableCell<Entry, String> implements ConfirmationsListener {
private static final Logger log = LoggerFactory.getLogger(LabelCell.class);
private IntegerProperty confirmationsProperty;
public LabelCell() {
super(new DefaultStringConverter());
getStyleClass().add("label-cell");
@@ -60,6 +66,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
super.commitEdit(label);
Platform.runLater(() -> getTreeTableView().requestFocus());
}
@Override
@@ -81,6 +88,21 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
}
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label");
@@ -0,0 +1,70 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.sparrow.io.ImageUtils;
import com.sparrowwallet.toucan.LifeHash;
import com.sparrowwallet.toucan.LifeHashVersion;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Group;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Rectangle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.image.BufferedImage;
import java.util.Arrays;
public class LifeHashIcon extends Group {
private static final Logger log = LoggerFactory.getLogger(LifeHashIcon.class);
private static final int SIZE = 24;
private final ObjectProperty<byte[]> dataProperty = new SimpleObjectProperty<>(null);
public LifeHashIcon() {
super();
dataProperty.addListener((observable, oldValue, data) -> {
if(data == null) {
getChildren().clear();
} else if(oldValue == null || !Arrays.equals(oldValue, data)) {
LifeHash.Image lifeHashImage = LifeHash.makeFromData(data, LifeHashVersion.VERSION2, 1, false);
BufferedImage bufferedImage = LifeHash.getBufferedImage(lifeHashImage);
BufferedImage resizedImage = ImageUtils.resizeToImage(bufferedImage, SIZE, SIZE);
Image image = SwingFXUtils.toFXImage(resizedImage, null);
setImage(image);
}
});
}
private void setImage(Image image) {
getChildren().clear();
Rectangle rectangle = new Rectangle(SIZE, SIZE);
rectangle.setArcWidth(6);
rectangle.setArcHeight(6);
rectangle.setFill(new ImagePattern(image));
rectangle.setStroke(Color.rgb(65, 72, 77));
rectangle.setStrokeWidth(1.0);
getChildren().add(rectangle);
}
public byte[] getData() {
return dataProperty.get();
}
public ObjectProperty<byte[]> dataProperty() {
return dataProperty;
}
public void setData(byte[] data) {
this.dataProperty.set(data);
}
public void setHex(String hex) {
setData(hex == null ? null : Utils.hexToBytes(hex));
}
}
@@ -1,21 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.collections.FXCollections;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.*;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat;
@@ -27,16 +38,100 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
public static final int MAX_PERIOD_HOURS = 2;
public static final int DEFAULT_MAX_PERIOD_HOURS = 2;
private static final double Y_VALUE_BREAK_MVB = 3.0;
private static final List<Integer> FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800);
private int maxPeriodHours = DEFAULT_MAX_PERIOD_HOURS;
private Tooltip tooltip;
private MempoolSizeFeeRatesChart expandedChart;
private final EventHandler<MouseEvent> expandedChartHandler = new EventHandler<>() {
@Override
public void handle(MouseEvent event) {
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle("Mempool by vBytes");
stage.initOwner(MempoolSizeFeeRatesChart.this.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
StackPane scenePane = new StackPane();
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
if(Config.get().getTheme() == Theme.DARK) {
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
VBox vBox = new VBox(20);
vBox.setPadding(new Insets(20, 20, 20, 20));
expandedChart = new MempoolSizeFeeRatesChart();
expandedChart.initialize();
expandedChart.getStyleClass().add("vsizeChart");
expandedChart.update(AppServices.getMempoolHistogram());
expandedChart.setLegendVisible(false);
expandedChart.setAnimated(false);
expandedChart.setPrefWidth(700);
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER);
ToggleGroup periodGroup = new ToggleGroup();
ToggleButton period2 = new ToggleButton("2H");
ToggleButton period24 = new ToggleButton("24H");
SegmentedButton periodButtons = new SegmentedButton(period2, period24);
periodButtons.setToggleGroup(periodGroup);
periodGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
expandedChart.maxPeriodHours = (newValue == period2 ? 2 : 24);
expandedChart.update(AppServices.getMempoolHistogram());
});
Optional<Date> optEarliest = AppServices.getMempoolHistogram().keySet().stream().findFirst();
period24.setDisable(optEarliest.isEmpty() || optEarliest.get().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().isAfter(LocalDateTime.now().minusHours(2)));
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
Button button = new Button("Close");
button.setOnAction(e -> {
stage.close();
});
buttonBox.getChildren().addAll(periodButtons, region, button);
vBox.getChildren().addAll(expandedChart, buttonBox);
scenePane.getChildren().add(vBox);
Scene scene = new Scene(scenePane);
AppServices.onEscapePressed(scene, stage::close);
AppServices.setStageIcon(stage);
stage.setScene(scene);
stage.setOnShowing(e -> {
AppServices.moveToActiveWindowScreen(stage, 800, 460);
});
stage.setOnHidden(e -> {
expandedChart = null;
});
stage.show();
}
}
};
public MempoolSizeFeeRatesChart() {
super(new CategoryAxis(), new NumberAxis());
}
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis);
setOnMouseClicked(expandedChartHandler);
}
public void initialize() {
getStyleClass().add("vsizeChart");
setCreateSymbols(false);
setCursor(Cursor.CROSSHAIR);
setVerticalGridLinesVisible(false);
@@ -78,17 +173,18 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
}
});
long previousFeeRate = 0;
for(Long feeRate : AppServices.FEE_RATES_RANGE) {
for(int i = 0; i < FEE_RATES_INTERVALS.size(); i++) {
int feeRate = FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : FEE_RATES_INTERVALS.get(i+1));
XYChart.Series<String, Number> series = new XYChart.Series<>();
series.setName(feeRate + "+ sats/vB");
series.setName(feeRate + "-" + (nextFeeRate == Integer.MAX_VALUE ? 900 : nextFeeRate));
long seriesTotalVSize = 0;
for(Date date : periodRateSizes.keySet()) {
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
long totalVSize = 0;
for(MempoolRateSize rateSize : rateSizes) {
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) {
if(rateSize.getFee() >= feeRate && rateSize.getFee() < nextFeeRate) {
totalVSize += rateSize.getVSize();
}
}
@@ -100,8 +196,19 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
if(seriesTotalVSize > 0) {
getData().add(series);
}
}
previousFeeRate = feeRate;
for(int i = 0; i < getData().size(); i++) {
Series<String, Number> series = getData().get(i);
Set<Node> nodes = lookupAll(".series" + i);
for(Node node : nodes) {
if(node.getStyleClass().contains("chart-series-area-line")) {
node.setStyle("-fx-stroke: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.2;");
} else {
node.setStyle("-fx-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.5;");
}
node.getStyleClass().remove("default-color" + i);
}
}
final double maxMvB = getMaxMvB(getData());
@@ -131,6 +238,10 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
numberAxis.setTickLabelsVisible(false);
numberAxis.setOpacity(0);
}
if(expandedChart != null) {
expandedChart.update(mempoolRateSizes);
}
}
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
@@ -138,7 +249,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
return mempoolRateSizes;
}
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
LocalDateTime period = LocalDateTime.now().minusHours(maxPeriodHours);
return mempoolRateSizes.entrySet().stream().filter(entry -> {
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return dateTime.isAfter(period);
@@ -200,11 +311,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
double mvb = kvb / 1000;
if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) {
String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB");
Label label = new Label(series.getName() + ": " + amount);
Label label = new Label(series.getName() + " sats/vB: " + amount);
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
if(i < 8) {
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
}
circle.setStyle("-fx-text-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.7;");
label.setGraphic(circle);
getChildren().add(label);
}
@@ -5,19 +5,16 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.Bip322;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.scene.control.*;
@@ -25,6 +22,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
@@ -40,6 +38,8 @@ import java.util.List;
import java.util.Locale;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
@@ -49,9 +49,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private final ToggleGroup formatGroup;
private final ToggleButton formatTrezor;
private final ToggleButton formatElectrum;
private final ToggleButton formatBip322;
private final Wallet wallet;
private WalletNode walletNode;
private boolean electrumSignatureFormat;
private boolean canSign;
private boolean closed;
/**
@@ -92,10 +93,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
if(walletNode != null) {
checkWalletSigning(walletNode.getWallet());
this.canSign = canSign(walletNode.getWallet());
}
if(wallet != null) {
checkWalletSigning(wallet);
this.canSign = canSign(wallet);
}
this.wallet = wallet;
@@ -128,7 +131,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address = new TextField();
address.getStyleClass().add("id");
address.setEditable(walletNode == null);
address.setTooltip(new Tooltip("Only Legacy (P2PKH), Nested Segwit (P2SH-P2WPKH) and Native Segwit (P2WPKH) singlesig addresses can sign"));
address.setTooltip(new Tooltip("Only singlesig addresses can sign"));
addressField.getInputs().add(address);
if(walletNode != null) {
@@ -139,17 +142,18 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
messageField.setText("Message:");
message = new TextArea();
message.setWrapText(true);
message.setPrefRowCount(10);
message.setStyle("-fx-pref-height: 180px");
message.setPrefRowCount(8);
message.setStyle("-fx-pref-height: 160px");
messageField.getInputs().add(message);
Field signatureField = new Field();
signatureField.setText("Signature:");
signature = new TextArea();
signature.getStyleClass().add("id");
signature.setPrefRowCount(2);
signature.setStyle("-fx-pref-height: 60px");
signature.setPrefRowCount(4);
signature.setStyle("-fx-pref-height: 80px");
signature.setWrapText(true);
signature.setOnMouseClicked(event -> signature.selectAll());
signatureField.getInputs().add(signature);
Field formatField = new Field();
@@ -157,16 +161,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
formatGroup = new ToggleGroup();
formatElectrum = new ToggleButton("Standard (Electrum)");
formatTrezor = new ToggleButton("BIP137 (Trezor)");
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor);
formatBip322 = new ToggleButton("BIP322 (Simple)");
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor, formatBip322);
formatButtons.setToggleGroup(formatGroup);
formatField.getInputs().add(formatButtons);
formatGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
electrumSignatureFormat = (newValue == formatElectrum);
});
formatButtons.setDisable(wallet != null && walletNode != null && wallet.getScriptType() == ScriptType.P2PKH);
fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
@@ -179,6 +178,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
formatButtons.setDisable(true);
}
ButtonType showQrButtonType = new javafx.scene.control.ButtonType("Sign by QR", ButtonBar.ButtonData.LEFT);
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -197,10 +197,20 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
}
} else {
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
dialogPane.getButtonTypes().addAll(showQrButtonType, signButtonType, verifyButtonType, doneButtonType);
Button showQrButton = (Button) dialogPane.lookupButton(showQrButtonType);
showQrButton.setDisable(wallet == null);
showQrButton.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE)));
showQrButton.setGraphicTextGap(5);
showQrButton.setOnAction(event -> {
showQr();
});
Button signButton = (Button) dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setDisable(!canSign);
signButton.setGraphic(getGlyph(getSignGlyph()));
signButton.setGraphicTextGap(5);
signButton.setOnAction(event -> {
signMessage();
});
@@ -212,7 +222,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
showQrButton.setDisable(!validAddress || (wallet == null));
signButton.setDisable(!validAddress || !canSign);
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
@@ -223,13 +234,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
showQrButton.setDisable(!valid || (wallet == null));
signButton.setDisable(!valid || !canSign);
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
if(valid) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
setFormatFromScriptType(address.getScriptType());
if(wallet != null) {
setWalletNodeFromAddress(wallet, address);
}
} catch(InvalidAddressException e) {
//can't happen
}
@@ -252,7 +267,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(ButtonBar.ButtonData.CANCEL_CLOSE));
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
setResultConverter(dialogButton -> dialogButton == showQrButtonType || dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
Platform.runLater(() -> {
if(address.getText().isEmpty()) {
@@ -261,7 +276,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
message.requestFocus();
}
formatGroup.selectToggle(formatElectrum);
if(wallet != null && walletNode != null) {
setFormatFromScriptType(wallet.getScriptType());
} else {
formatGroup.selectToggle(formatElectrum);
}
});
}
@@ -269,9 +288,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore");
}
}
private boolean canSign(Wallet wallet) {
return wallet.getKeystores().get(0).hasPrivateKey()
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().get(0).getWalletModel().isCard();
}
private Address getAddress()throws InvalidAddressException {
@@ -282,20 +304,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return signature.getText();
}
/**
* Use the Electrum signing format, which uses the non-segwit compressed signing parameters for both segwit types (p2sh-p2wpkh and p2wpkh)
*
* @param electrumSignatureFormat
*/
public void setElectrumSignatureFormat(boolean electrumSignatureFormat) {
formatGroup.selectToggle(electrumSignatureFormat ? formatElectrum : formatTrezor);
this.electrumSignatureFormat = electrumSignatureFormat;
}
private boolean isValidAddress() {
try {
Address address = getAddress();
return address.getScriptType() != ScriptType.P2TR && address.getScriptType().isAllowed(PolicyType.SINGLE);
return address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH;
} catch (InvalidAddressException e) {
return false;
}
@@ -305,12 +317,36 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address);
}
private void setFormatFromScriptType(ScriptType scriptType) {
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
formatBip322.setDisable(scriptType != ScriptType.P2WPKH && scriptType != ScriptType.P2TR);
if(scriptType == ScriptType.P2TR) {
formatGroup.selectToggle(formatBip322);
} else if(formatGroup.getSelectedToggle() == null || scriptType == ScriptType.P2PKH || (scriptType != ScriptType.P2WPKH && formatBip322.isSelected())) {
formatGroup.selectToggle(formatElectrum);
}
}
private boolean isBip322() {
return formatBip322.isSelected();
}
private boolean isElectrumSignatureFormat() {
return formatElectrum.isSelected();
}
private void signMessage() {
if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
if(!canSign) {
AppServices.showErrorDialog("Wallet can't sign", "This wallet cannot sign a message.");
return;
}
//Note we can expect a single keystore due to the check in the constructor
Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
@@ -319,8 +355,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} else {
signUnencryptedKeystore(signingWallet);
}
} else if(signingWallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(signingWallet);
} else if(signingWallet.containsSource(KeystoreSource.HW_USB) || wallet.getKeystores().get(0).getWalletModel().isCard()) {
signDeviceKeystore(signingWallet);
}
}
@@ -328,8 +364,14 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
try {
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();
String signatureText = privKey.signMessage(message.getText().trim(), scriptType);
String signatureText;
if(isBip322()) {
ScriptType scriptType = decryptedWallet.getScriptType();
signatureText = Bip322.signMessageBip322(scriptType, message.getText().trim(), privKey);
} else {
ScriptType scriptType = isElectrumSignatureFormat() ? ScriptType.P2PKH : decryptedWallet.getScriptType();
signatureText = privKey.signMessage(message.getText().trim(), scriptType);
}
signature.clear();
signature.appendText(signatureText);
privKey.clear();
@@ -339,10 +381,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
private void signUsbKeystore(Wallet usbWallet) {
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation);
private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
signature.clear();
@@ -356,16 +399,33 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
boolean verified = false;
try {
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), false);
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), true);
verified = verifyMessage(signedMessageKey);
if(verified) {
formatGroup.selectToggle(formatElectrum);
}
} catch(SignatureException e) {
//ignore
}
if(!verified) {
try {
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), false);
verified = verifyMessage(electrumSignedMessageKey);
if(verified) {
formatGroup.selectToggle(formatTrezor);
}
} catch(SignatureException e) {
//ignore
}
}
if(!verified && Bip322.isSupported(getAddress().getScriptType()) && !signature.getText().trim().isEmpty()) {
try {
verified = Bip322.verifyMessageBip322(getAddress().getScriptType(), getAddress(), message.getText().trim(), signature.getText().trim());
if(verified) {
formatGroup.selectToggle(formatBip322);
}
} catch(SignatureException e) {
//ignore
}
@@ -398,6 +458,60 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return providedAddress.equals(signedMessageAddress);
}
private void showQr() {
if(walletNode == null) {
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
//Note we can expect a single keystore due to the check in the constructor
KeyDerivation firstDerivation = walletNode.getWallet().getKeystores().get(0).getKeyDerivation();
String derivationPath = KeyDerivation.writePath(firstDerivation.extend(walletNode.getDerivation()).getDerivation(), false);
String qrText = "signmessage " + derivationPath + " ascii:" + message.getText().trim();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(qrText, true);
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
scanQr();
}
}
private void scanQr() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.payload != null) {
signature.clear();
signature.appendText(result.payload);
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
showErrorDialog("Error scanning QR", result.exception.getMessage());
} else {
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a signature.");
}
}
}
protected Glyph getSignGlyph() {
if(wallet != null) {
if(wallet.containsSource(KeystoreSource.HW_USB)) {
return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
} else if(wallet.getKeystores().get(0).getWalletModel().isCard()) {
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
}
}
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
}
private static Glyph getGlyph(Glyph glyph) {
glyph.setFontSize(11);
return glyph;
}
@Subscribe
public void openWallets(OpenWalletsEvent event) {
Storage storage = event.getStorage(wallet);
@@ -407,6 +521,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(getDialogPane().getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get());
@@ -418,7 +533,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
AppServices.showErrorDialog("Incorrect Password", "The password was incorrect.");
});
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
@@ -0,0 +1,429 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.FileChooser;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.stream.Collectors;
public class MnemonicGridDialog extends Dialog<List<String>> {
private final SpreadsheetView spreadsheetView;
private final int GRID_COLUMN_COUNT = 16;
private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false);
private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false);
private final ObservableList<TablePosition> selectedCells = FXCollections.observableArrayList();
public MnemonicGridDialog() {
DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
setTitle("Border Wallets Grid");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm());
dialogPane.setHeaderText("Load a Border Wallets PDF, or generate a grid from a BIP39 seed.\nThen select 11 or 23 words in a pattern on the grid.\nThe order of selection is important!");
javafx.scene.image.Image image = new Image("/image/border-wallets.png");
dialogPane.setGraphic(new ImageView(image));
String[][] emptyWordGrid = new String[128][GRID_COLUMN_COUNT];
Grid grid = getGrid(emptyWordGrid);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.setId("grid");
spreadsheetView.setEditable(false);
spreadsheetView.setFixingColumnsAllowed(false);
spreadsheetView.setFixingRowsAllowed(false);
spreadsheetView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() {
@Override
public void onChanged(Change<? extends TablePosition> c) {
while(c.next()) {
if(c.wasAdded()) {
for(TablePosition<?, ?> pos : c.getAddedSubList()) {
if(selectedCells.contains(pos)) {
selectedCells.remove(pos);
} else {
selectedCells.add(pos);
}
}
}
}
int numWords = selectedCells.size();
wordsSelectedProperty.set(numWords == 11 || numWords == 23);
}
});
selectedCells.addListener((ListChangeListener<? super TablePosition>) c -> {
while(c.next()) {
if(c.wasRemoved()) {
for(TablePosition<?,?> pos : c.getRemoved()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().remove("selection");
cell.setGraphic(null);
}
}
if(c.wasAdded()) {
for(TablePosition<?,?> pos : c.getAddedSubList()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().add("selection");
}
}
for(int i = 0; i < selectedCells.size(); i++) {
Text index = new Text(Integer.toString(i+1));
index.setFont(Font.font(8));
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(selectedCells.get(i).getRow()).get(selectedCells.get(i).getColumn());
cell.setGraphic(index);
}
}
});
StackPane stackPane = new StackPane();
stackPane.getChildren().add(spreadsheetView);
dialogPane.setContent(stackPane);
stackPane.widthProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
for(SpreadsheetColumn column : spreadsheetView.getColumns()) {
column.setPrefWidth((newValue.doubleValue() - spreadsheetView.getRowHeaderWidth() - 3) / 17);
}
}
});
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate Grid...", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().add(generateButtonType);
final ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear Selection", ButtonBar.ButtonData.OTHER);
dialogPane.getButtonTypes().add(clearButtonType);
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
okButton.disableProperty().bind(Bindings.not(Bindings.and(initializedProperty, wordsSelectedProperty)));
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null;
});
dialogPane.setPrefWidth(952);
dialogPane.setPrefHeight(500);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
private Grid getGrid(String[][] wordGrid) {
int rowCount = wordGrid.length;
int columnCount = wordGrid[0].length;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
grid.getColumnHeaders().setAll("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P");
for(int i = 0; i < rowCount; i++) {
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
for(int j = 0; j < columnCount; j++) {
list.add(createCell(i, j, wordGrid[i][j]));
}
rows.add(list);
grid.getRowHeaders().add(String.format("%03d", i + 1));
}
grid.setRows(rows);
return grid;
}
private SpreadsheetCell createCell(int row, int column, String word) {
return SpreadsheetCellType.STRING.createCell(row, column, 1, 1, word == null ? "" : word);
}
private List<String> getSelectedWords() {
List<String> abbreviations = selectedCells.stream()
.map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList());
boolean isInteger = abbreviations.stream().allMatch(Utils::isNumber);
boolean isHex = abbreviations.stream().allMatch(Utils::isHex);
List<String> words = new ArrayList<>();
for(String abbreviation : abbreviations) {
if(isInteger) {
try {
int index = Integer.parseInt(abbreviation);
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
} catch(NumberFormatException e) {
//ignore
}
} else if(isHex) {
try {
int index = Integer.parseInt(abbreviation, 16);
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
} catch(NumberFormatException e) {
//ignore
}
} else {
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
words.add(word);
}
}
}
}
if(words.size() != abbreviations.size()) {
abbreviations.removeIf(abbr -> words.stream().anyMatch(w -> w.startsWith(abbr)));
throw new IllegalStateException("Could not find words for abbreviations: " + abbreviations);
}
return words;
}
public List<String> shuffle(List<String> mnemonic) {
String mnemonicString = String.join(" ", mnemonic);
List<String> words = new ArrayList<>(Bip39MnemonicCode.INSTANCE.getWordList());
UltraHighEntropyPrng uhePrng = new UltraHighEntropyPrng();
uhePrng.initState();
uhePrng.hashString(mnemonicString);
for(int i = words.size() - 1; i > 0; i--) {
int j = (int)uhePrng.random(i + 1);
String tmp = words.get(i);
words.set(i, words.get(j));
words.set(j, tmp);
}
return words;
}
private String[][] toGrid(List<String> words) {
String[][] grid = new String[words.size()/GRID_COLUMN_COUNT][GRID_COLUMN_COUNT];
int row = 0;
int col = 0;
for(String word : words) {
String abbr = word.length() < 4 ? word : word.substring(0, 4);
grid[row][col] = abbr;
col++;
if(col >= GRID_COLUMN_COUNT) {
col = 0;
row++;
}
}
return grid;
}
private class MnemonicGridDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open PDF");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("PDF", "*.pdf")
);
AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450);
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
String[][] wordGrid = PdfUtils.getWordGrid(inputStream);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
} catch(Exception e) {
AppServices.showErrorDialog("Cannot load PDF", e.getMessage());
}
}
});
button = loadButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
SeedEntryDialog seedEntryDialog = new SeedEntryDialog("Border Wallets Entropy Grid Recovery Seed", 12);
seedEntryDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
List<String> mnemonicWords = optWords.get();
List<String> shuffledWordList = shuffle(mnemonicWords);
String[][] wordGrid = toGrid(shuffledWordList);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
if(seedEntryDialog.isGenerated()) {
PdfUtils.saveWordGrid(wordGrid, mnemonicWords);
}
}
});
button = generateButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OTHER) {
Button clearButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(clearButton, buttonData);
clearButton.setOnAction(event -> {
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
});
button = clearButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class UltraHighEntropyPrng {
private final int order;
private double carry;
private int phase;
private final double[] intermediates;
private int i, j, k; // general purpose locals
public UltraHighEntropyPrng() {
order = 48; // set the 'order' number of ENTROPY-holding 32-bit values
carry = 1; // init the 'carry' used by the multiply-with-carry (MWC) algorithm
phase = order; // init the 'phase' (max-1) of the intermediate variable pointer
intermediates = new double[order]; // declare our intermediate variables array
for(i = 0; i < order; i++) {
// Used to simulate javascript's Math.random
Random random = new Random();
intermediates[i] = mash(random.nextInt(Integer.MAX_VALUE)); // fill the array with initial mash hash values
}
}
public double random(int range) {
return Math.floor(range * (rawPrng() + ((long)(rawPrng() * 0x200000L)) * 1.1102230246251565e-16)); // 2^-53
}
private String randomString(int count) {
StringBuilder stringBuilder = new StringBuilder();
for(i = 0; i < count; i++) {
char newChar = (char) (33 + random(94));
stringBuilder.append(newChar);
}
return stringBuilder.toString();
}
private double rawPrng() {
if(++phase >= order) {
phase = 0;
}
double t = 1768863 * intermediates[phase] + carry * 2.3283064365386963e-10; // 2^-32
long temp = (long)t;
return intermediates[phase] = t - (carry = temp);
}
private void hash(String args) {
for(i = 0; i < args.length(); i++) {
for(j = 0; j < order; j++) {
intermediates[j] -= mash(args.charAt(i));
if(intermediates[j] < 0) {
intermediates[j] = intermediates[j] + 1;
}
}
}
}
public void hashString(String input) {
mash(input); // use the string to evolve the 'mash' state
char[] inputAry = input.toCharArray();
for(i = 0; i < inputAry.length; i++) // scan through the characters in our string
{
k = inputAry[i]; // get the character code at the location
for(j = 0; j < order; j++) // "mash" it into the UHEPRNG state
{
intermediates[j] -= mash(k);
if(intermediates[j] < 0) {
intermediates[j] += 1;
}
}
}
}
public void initState() {
mash(null);
for(i = 0; i < order; i++) {
intermediates[i] = mash(' '); // fill the array with initial mash hash values
}
carry = 1; // init our multiply-with-carry carry
phase = order; // init our phase
}
double n = Integer.toUnsignedLong(0xefc8249d);
private double mash(Object data) {
if(data != null) {
String strData = data.toString();
for(int i = 0; i < strData.length(); i++) {
n += strData.charAt(i);
double h = 0.02519603282416938 * n;
n = Integer.toUnsignedLong((int)h);
h -= n;
h *= n;
n = Integer.toUnsignedLong((int)h);
h -= n;
n += h * 0x100000000L; // 2^32
}
return ((long)n) * 2.3283064365386963e-10; // 2^-32
} else {
n = Integer.toUnsignedLong(0xefc8249d);
}
return n;
}
}
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.SimpleListProperty;
@@ -24,7 +23,8 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
showWordList(keystore.getSeed());
}
protected Node getMnemonicWordsEntry(int numWords) {
@Override
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -0,0 +1,95 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node;
import javafx.scene.control.Button;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.List;
public class MnemonicKeystoreEntryPane extends MnemonicKeystorePane {
private final BooleanProperty validProperty = new SimpleBooleanProperty(false);
private boolean generated;
public MnemonicKeystoreEntryPane(String name, int numWords) {
super(name, "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
defaultWordSizeProperty.set(numWords);
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords, false, true));
setExpanded(true);
}
@Override
protected List<Node> createRightButtons() {
Button button = new Button("Next");
button.setVisible(false);
return List.of(button);
}
@Override
protected void onWordChange(boolean empty, boolean validWords, boolean validChecksum) {
if(!empty && validWords) {
try {
Bip39MnemonicCode.INSTANCE.check(wordEntriesProperty.get());
validChecksum = true;
} catch(MnemonicException e) {
invalidLabel.setText("Invalid checksum");
invalidLabel.setTooltip(null);
}
}
validProperty.set(validChecksum);
validLabel.setVisible(validChecksum);
invalidLabel.setVisible(!validChecksum && !empty);
}
public void generateNew() {
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
SecureRandom secureRandom;
try {
secureRandom = SecureRandom.getInstanceStrong();
} catch(NoSuchAlgorithmException e) {
secureRandom = new SecureRandom();
}
DeterministicSeed deterministicSeed = new DeterministicSeed(secureRandom, entropyLength, "");
displayMnemonicCode(deterministicSeed);
generated = true;
}
private void displayMnemonicCode(DeterministicSeed deterministicSeed) {
setDescription("Write down these words");
showHideLink.setVisible(false);
for (int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(deterministicSeed.getMnemonicCode().get(i));
wordEntry.getEditor().setEditable(false);
}
}
public boolean isValid() {
return validProperty.get();
}
public BooleanProperty validProperty() {
return validProperty;
}
public boolean isGenerated() {
return generated;
}
}
@@ -223,7 +223,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private void confirmBackup() {
setDescription("Confirm backup by re-entering words");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size()));
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), true, false));
setExpanded(true);
backButton.setVisible(true);
generateButton.setVisible(false);
@@ -254,6 +254,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
if(!dryrun) {
if(passphraseProperty.get() != null && !passphraseProperty.get().isEmpty()) {
KeystorePassphraseDialog keystorePassphraseDialog = new KeystorePassphraseDialog(null, keystore, true);
keystorePassphraseDialog.initOwner(this.getScene().getWindow());
Optional<String> optPassphrase = keystorePassphraseDialog.showAndWait();
if(optPassphrase.isEmpty() || !optPassphrase.get().equals(passphraseProperty.get())) {
throw new ImportException("Re-entered passphrase did not match");
@@ -12,6 +12,8 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
@@ -20,8 +22,8 @@ import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.*;
import javafx.util.Callback;
import javafx.util.Duration;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -44,7 +46,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected Label invalidLabel;
protected SimpleListProperty<String> wordEntriesProperty;
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty("");
protected IntegerProperty defaultWordSizeProperty;
public MnemonicKeystorePane(String title, String description, String content, String imageUrl) {
@@ -79,6 +81,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.getItems().add(item);
}
enterMnemonicButton.getItems().add(new SeparatorMenuItem());
MenuItem gridItem = new MenuItem("Border Wallets Grid...");
gridItem.setOnAction(event -> {
showGrid();
});
enterMnemonicButton.getItems().add(gridItem);
MenuItem scanItem = new MenuItem("Scan QR...");
scanItem.setOnAction(event -> {
scanQR();
@@ -87,8 +95,47 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
}
protected void showGrid() {
MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog();
mnemonicGridDialog.initOwner(this.getScene().getWindow());
Optional<List<String>> optWords = mnemonicGridDialog.showAndWait();
if(optWords.isPresent()) {
List<String> words = optWords.get();
defaultWordSizeProperty.set(words.size() + 1);
setContent(getMnemonicWordsEntry(words.size() + 1, true, true));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
if(i < words.size()) {
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
} else {
ScheduledService<Void> service = new ScheduledService<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
return null;
}
};
}
};
service.setDelay(Duration.millis(500));
service.setOnSucceeded(event1 -> {
service.cancel();
Platform.runLater(() -> wordEntry.getEditor().requestFocus());
});
service.start();
}
}
}
}
protected void scanQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(this.getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -106,7 +153,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setContent(getMnemonicWordsEntry(words.size(), true, true));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
@@ -119,11 +166,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void enterMnemonic(int numWords) {
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords));
setContent(getMnemonicWordsEntry(numWords, true, true));
setExpanded(true);
}
protected Node getMnemonicWordsEntry(int numWords) {
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -152,10 +199,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
vBox.getChildren().add(wordsPane);
PassphraseEntry passphraseEntry = new PassphraseEntry();
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
if(showPassphrase) {
PassphraseEntry passphraseEntry = new PassphraseEntry(editPassphrase);
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
}
AnchorPane buttonPane = new AnchorPane();
buttonPane.setPadding(new Insets(0, 26, 0, 10));
@@ -250,9 +299,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) {
String[] words = clipboard.getString().split("[\\n\\s]");
WordEntry entry = WordEntry.this;
for(String word : words) {
for(int i = 0; i < words.length; i++) {
String word = words[i];
if(entry.nextField != null) {
entry.nextField.requestFocus();
if(i == words.length - 2 && isValid(word)) {
label.requestFocus();
} else {
entry.nextField.requestFocus();
}
}
entry.wordField.setText(word);
@@ -282,7 +336,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
wordField.setTextFormatter(formatter);
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList, wordNumber, wordEntryList));
autoCompletionBinding.setDelay(50);
autoCompletionBinding.setOnAutoCompleted(event -> {
if(nextField != null) {
@@ -290,11 +344,20 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
}
});
//Show autocomplete for the last word on focus if empty
boolean lastWord = wordNumber == wordEntryList.size() - 1;
if(lastWord) {
wordField.focusedProperty().addListener((observable, oldValue, focused) -> {
if(focused && wordField.getText().isEmpty()) {
autoCompletionBinding.setUserInput("");
}
});
}
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(wordField, Validator.combine(
Validator.createEmptyValidator("Word is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", (newValue.length() > 0 || !lastWord) && !wordList.contains(newValue))
));
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
@@ -323,13 +386,30 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> {
private final List<String> wordList;
private final int wordNumber;
private final ObservableList<String> wordEntryList;
public WordlistSuggestionProvider(List<String> wordList) {
public WordlistSuggestionProvider(List<String> wordList, int wordNumber, ObservableList<String> wordEntryList) {
this.wordList = wordList;
this.wordNumber = wordNumber;
this.wordEntryList = wordEntryList;
}
@Override
public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) {
if(wordNumber == wordEntryList.size() - 1 && allPreviousWordsValid()) {
try {
List<String> possibleLastWords = Bip39MnemonicCode.INSTANCE.getPossibleLastWords(wordEntryList.subList(0, wordEntryList.size() - 1));
if(!request.getUserText().isEmpty()) {
possibleLastWords.removeIf(s -> !s.startsWith(request.getUserText()));
}
return possibleLastWords;
} catch(Exception e) {
log.warn("Cannot determine possible last words", e);
}
}
List<String> suggestions = new ArrayList<>();
if(!request.getUserText().isEmpty()) {
for(String word : wordList) {
@@ -341,26 +421,63 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
return suggestions;
}
private boolean allPreviousWordsValid() {
for(int i = 0; i < wordEntryList.size() - 1; i++) {
if(!WordEntry.isValid(wordEntryList.get(i))) {
return false;
}
}
return true;
}
}
protected class PassphraseEntry extends HBox {
private final CustomTextField passphraseField;
private final TextField passphraseField;
public PassphraseEntry() {
public PassphraseEntry(boolean editable) {
super();
setAlignment(Pos.CENTER_LEFT);
setSpacing(10);
Label usePassphraseLabel = new Label("Use passphrase?");
usePassphraseLabel.managedProperty().bind(usePassphraseLabel.visibleProperty());
CheckBox usePassphraseCheckbox = new CheckBox(" ");
usePassphraseCheckbox.setDisable(!editable);
usePassphraseCheckbox.managedProperty().bind(usePassphraseCheckbox.visibleProperty());
usePassphraseLabel.visibleProperty().bind(usePassphraseCheckbox.visibleProperty());
usePassphraseCheckbox.setVisible(passphraseProperty.isEmpty().get());
usePassphraseCheckbox.selectedProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Add a passphrase?", "A passphrase is not a password! " +
"Any variation entered in future loads a valid wallet, but with different addresses.\n\n" +
"This feature provides optional added security for advanced users only. Are you sure?", ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.YES) {
usePassphraseCheckbox.setVisible(false);
} else {
usePassphraseCheckbox.setSelected(false);
}
}
});
Label passphraseLabel = new Label("Passphrase:");
passphraseField = (CustomTextField) TextFields.createClearableTextField();
passphraseLabel.managedProperty().bind(passphraseLabel.visibleProperty());
passphraseField = new TextField();
passphraseField.setPromptText(passphraseProperty.isEmpty().get() ? "Leave blank for none" : "");
passphraseField.setText(passphraseProperty.get());
passphraseField.setDisable(!editable);
passphraseField.managedProperty().bind(passphraseField.visibleProperty());
passphraseLabel.visibleProperty().bind(passphraseField.visibleProperty());
passphraseField.visibleProperty().bind(usePassphraseCheckbox.visibleProperty().not());
passphraseProperty.bind(passphraseField.textProperty());
passphraseField.setPromptText("Leave blank for none");
HelpLabel helpLabel = new HelpLabel();
helpLabel.setPrefHeight(28);
helpLabel.setStyle("-fx-padding: 0 0 0 0");
helpLabel.setHelpText("A passphrase provides optional added security - it is not stored so it must be remembered!");
helpLabel.setHelpText("Advanced feature: a passphrase provides optional added security, but it is not stored so it must be remembered!");
getChildren().addAll(passphraseLabel, passphraseField, helpLabel);
getChildren().addAll(usePassphraseLabel, usePassphraseCheckbox, passphraseLabel, passphraseField, helpLabel);
}
public TextField getEditor() {
@@ -10,9 +10,11 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreMnemonicImport;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -107,6 +109,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
List<List<ChildNumber>> derivations = ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).stream().map(ScriptType::getDefaultDerivation).collect(Collectors.toList());
derivations.add(List.of(new ChildNumber(0, true)));
derivations.add(ScriptType.P2PKH.getDefaultDerivation(1)); //Bisq segwit misderivation
for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE)) {
for(List<ChildNumber> derivation : derivations) {
@@ -135,7 +138,10 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
EventManager.get().post(new WalletImportEvent(optWallet.get()));
} else {
discoverButton.setDisable(false);
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found", "Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found",
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
"You can however import this wallet and scan the blockchain by supplying a start date. Do you want to import this wallet?" :
"Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
setContent(getScriptTypeEntry());
setExpanded(true);
@@ -183,7 +189,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
scriptTypeComboBox.setMaxWidth(170);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
helpLabel.setHelpText("Native Segwit is usually the best choice for new wallets.\nTaproot is a new type useful for specific needs.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel);
Region region = new Region();
@@ -2,8 +2,11 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.PayNymImageLoadedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
@@ -128,6 +131,7 @@ public class PayNymAvatar extends StackPane {
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false);
paymentCodeCache.put(cacheId, image);
Platform.runLater(() -> EventManager.get().post(new PayNymImageLoadedEvent(paymentCode, image)));
return image;
} catch(Exception e) {
log.debug("Error loading PayNym avatar", e);
@@ -110,6 +110,7 @@ public class PayNymCell extends ListCell<PayNym> {
MenuItem rename = new MenuItem("Rename Contact...");
rename.setOnAction(event -> {
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
walletLabelDialog.initOwner(PayNymCell.this.getScene().getWindow());
Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) {
int index = getListView().getItems().indexOf(payNym);
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.Files;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
@@ -13,8 +15,14 @@ import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
@@ -42,9 +50,7 @@ import tornadofx.control.Form;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
@@ -57,6 +63,8 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private final CopyableLabel keyAddress;
private final ComboBoxTextField toAddress;
private final ComboBox<Wallet> toWallet;
private final FeeRangeSlider feeRange;
private final CopyableLabel feeRate;
public PrivateKeySweepDialog(Wallet wallet) {
final DialogPane dialogPane = getDialogPane();
@@ -96,6 +104,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
HBox.setHgrow(key, Priority.ALWAYS);
keyField.getInputs().add(keyBox);
if(CardApi.isReaderAvailable()) {
VBox cardButtonBox = new VBox(5);
Button cardKey = new Button("", getGlyph(FontAwesome5.Glyph.WIFI));
cardKey.setOnAction(event -> unsealPrivateKey());
cardButtonBox.getChildren().add(cardKey);
keyBox.getChildren().add(cardButtonBox);
}
Field keyScriptTypeField = new Field();
keyScriptTypeField.setText("Script Type:");
keyScriptType = new ComboBox<>();
@@ -133,7 +149,24 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
stackPane.getChildren().addAll(toWallet, toAddress);
toAddressField.getInputs().add(stackPane);
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField);
Field feeRangeField = new Field();
feeRangeField.setText("Fee range:");
feeRange = new FeeRangeSlider();
feeRange.setMaxWidth(320);
feeRangeField.getInputs().add(feeRange);
Field feeRateField = new Field();
feeRateField.setText("Fee rate:");
feeRate = new CopyableLabel();
feeRateField.getInputs().add(feeRate);
feeRange.valueProperty().addListener((observable, oldValue, newValue) -> {
updateFeeRate();
});
feeRange.setFeeRate(AppServices.getDefaultFeeRate());
updateFeeRate();
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField, feeRangeField, feeRateField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
@@ -188,6 +221,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
setResultConverter(dialogButton -> null);
dialogPane.setPrefWidth(680);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
@@ -211,6 +249,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private void decryptKey() {
PassphraseDialog passphraseDialog = new PassphraseDialog();
passphraseDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optPassphrase = passphraseDialog.showAndWait();
if(optPassphrase.isPresent()) {
try {
@@ -252,6 +291,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private void scanPrivateKey() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
if(result.isPresent() && result.get().payload != null) {
key.setText(result.get().payload);
@@ -279,6 +319,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
}
}
private void unsealPrivateKey() {
DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList());
deviceUnsealDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<DeviceUnsealDialog.DevicePrivateKey> optPrivateKey = deviceUnsealDialog.showAndWait();
if(optPrivateKey.isPresent()) {
DeviceUnsealDialog.DevicePrivateKey devicePrivateKey = optPrivateKey.get();
key.setText(devicePrivateKey.privateKey().getPrivateKeyEncoded().toBase58());
keyScriptType.setValue(devicePrivateKey.scriptType());
}
}
private void createTransaction() {
try {
DumpedPrivateKey privateKey = getPrivateKey();
@@ -286,14 +337,32 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
Address fromAddress = scriptType.getAddress(privateKey.getKey());
Address destAddress = getToAddress();
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress);
Date since = null;
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
WalletBirthDateDialog addressScanDateDialog = new WalletBirthDateDialog(null, true);
addressScanDateDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<Date> optSince = addressScanDateDialog.showAndWait();
if(optSince.isPresent()) {
since = optSince.get();
}
}
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress, since);
addressUtxosService.setOnSucceeded(successEvent -> {
createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress);
});
addressUtxosService.setOnFailed(failedEvent -> {
Throwable rootCause = Throwables.getRootCause(failedEvent.getSource().getException());
log.error("Error retrieving outputs for address " + fromAddress, failedEvent.getSource().getException());
AppServices.showErrorDialog("Error retrieving outputs for address", failedEvent.getSource().getException().getMessage());
AppServices.showErrorDialog("Error retrieving outputs for address", rootCause.getMessage());
});
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
ServiceProgressDialog serviceProgressDialog = new ServiceProgressDialog("Address Scan", "Scanning address for transactions...", "/image/sparrow.png", addressUtxosService);
serviceProgressDialog.initOwner(getDialogPane().getScene().getWindow());
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
}
addressUtxosService.start();
} catch(Exception e) {
log.error("Error creating sweep transaction", e);
@@ -313,7 +382,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
noFeeTransaction.addOutput(sweepOutput);
Double feeRate = AppServices.getDefaultFeeRate();
double feeRate = feeRange.getFeeRate();
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
fee++;
@@ -321,8 +390,13 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
feeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + 1;
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
}
}
Transaction transaction = new Transaction();
@@ -334,11 +408,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
PSBT psbt = new PSBT(transaction);
//Set witness UTXOs on PSBT inputs first - they are all required when hashing for a Taproot signature
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
}
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
TransactionInput txInput = transaction.getInputs().get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
@@ -370,6 +450,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
return glyph;
}
private void updateFeeRate() {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
feeRate.setText(format.getCurrencyFormat().format(feeRange.getFeeRate()) + " sats/vB");
}
@Subscribe
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
feeRange.updateTrackHighlight();
}
public class PassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase;
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.control;
public enum QRDensity {
NORMAL("Normal", 400),
LOW("Low", 80);
private final String name;
private final int maxFragmentLength;
QRDensity(String name, int maxFragmentLength) {
this.name = name;
this.maxFragmentLength = maxFragmentLength;
}
public String getName() {
return name;
}
public int getMaxFragmentLength() {
return maxFragmentLength;
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
@@ -9,6 +10,7 @@ import com.sparrowwallet.hummingbird.LegacyUREncoder;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
@@ -28,42 +30,49 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@SuppressWarnings("deprecation")
public class QRDisplayDialog extends Dialog<UR> {
public class QRDisplayDialog extends Dialog<ButtonType> {
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int MAX_FRAGMENT_LENGTH = 250;
private static final int ANIMATION_PERIOD_MILLIS = 200;
private static final int QR_WIDTH = 480;
private static final int QR_HEIGHT = 480;
private static final int DEFAULT_QR_SIZE = 580;
private static final int REDUCED_QR_SIZE = 520;
private final int qrSize = getQRSize();
private final UR ur;
private final UREncoder encoder;
private UREncoder encoder;
private final ImageView qrImageView;
private AnimateQRService animateQRService;
private String currentPart;
private boolean addLegacyEncodingOption;
private boolean useLegacyEncoding;
private String[] legacyParts;
private int legacyPartIndex;
private static boolean initialDensityChange;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), addLegacyEncodingOption);
this(UR.fromBytes(type, data), addLegacyEncodingOption, false);
}
public QRDisplayDialog(UR ur) {
this(ur, false);
this(ur, false, false);
}
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) {
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption, boolean addScanButton) {
this.ur = ur;
this.encoder = new UREncoder(ur, MAX_FRAGMENT_LENGTH, MIN_FRAGMENT_LENGTH, 0);
this.addLegacyEncodingOption = addLegacyEncodingOption;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
@@ -88,20 +97,34 @@ public class QRDisplayDialog extends Dialog<UR> {
if(addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
} else {
final ButtonType densityButtonType = new javafx.scene.control.ButtonType("Change Density", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(densityButtonType);
}
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
dialogPane.getButtonTypes().add(scanButtonType);
}
dialogPane.setPrefWidth(40 + qrSize + 40);
dialogPane.setPrefHeight(40 + qrSize + 85);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
setResultConverter(dialogButton -> dialogButton);
}
public QRDisplayDialog(String data) {
this(data, false);
}
public QRDisplayDialog(String data, boolean addScanButton) {
this.ur = null;
this.encoder = null;
final DialogPane dialogPane = getDialogPane();
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
@@ -113,11 +136,22 @@ public class QRDisplayDialog extends Dialog<UR> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
dialogPane.getButtonTypes().add(scanButtonType);
}
dialogPane.setPrefWidth(40 + qrSize + 40);
dialogPane.setPrefHeight(40 + qrSize + 85);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
setResultConverter(dialogButton -> dialogButton);
}
private int getQRSize() {
return AppServices.isReducedWindowHeight() ? REDUCED_QR_SIZE : DEFAULT_QR_SIZE;
}
private void createAnimateQRService() {
@@ -145,7 +179,7 @@ public class QRDisplayDialog extends Dialog<UR> {
protected Image getQrCode(String fragment) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, qrSize, qrSize, Map.of(EncodeHintType.MARGIN, "2"));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
@@ -201,6 +235,20 @@ public class QRDisplayDialog extends Dialog<UR> {
}
}
private void changeQRDensity() {
if(animateQRService != null) {
animateQRService.cancel();
}
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
nextPart();
if(encoder.isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
createAnimateQRService();
}
}
private class AnimateQRService extends ScheduledService<Boolean> {
@Override
protected Task<Boolean> createTask() {
@@ -220,18 +268,50 @@ public class QRDisplayDialog extends Dialog<UR> {
@Override
protected Node createButton(ButtonType buttonType) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton legacy = new ToggleButton(buttonType.getText());
legacy.setGraphicTextGap(5);
setLegacyGraphic(legacy, false);
if(addLegacyEncodingOption) {
ToggleButton legacy = new ToggleButton(buttonType.getText());
legacy.setGraphicTextGap(5);
setLegacyGraphic(legacy, false);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(legacy, buttonData);
legacy.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseLegacyEncoding(newValue);
setLegacyGraphic(legacy, newValue);
});
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(legacy, buttonData);
legacy.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseLegacyEncoding(newValue);
setLegacyGraphic(legacy, newValue);
});
return legacy;
return legacy;
} else {
Button density = new Button(buttonType.getText());
density.setPrefWidth(160);
density.setGraphicTextGap(5);
updateDensityButton(density);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(density, buttonData);
density.setOnAction(event -> {
if(!initialDensityChange && !encoder.isSinglePart()) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
initialDensityChange = true;
} else {
return;
}
}
Config.get().setQrDensity(Config.get().getQrDensity() == QRDensity.NORMAL ? QRDensity.LOW : QRDensity.NORMAL);
updateDensityButton(density);
changeQRDensity();
});
return density;
}
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
Button scanButton = (Button)super.createButton(buttonType);
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
return scanButton;
}
return super.createButton(buttonType);
@@ -244,6 +324,15 @@ public class QRDisplayDialog extends Dialog<UR> {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
private void updateDensityButton(Button density) {
density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Decrease Density" : "Increase Density");
if(Config.get().getQrDensity() == QRDensity.NORMAL) {
density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_PLUS));
} else {
density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_MINUS));
}
}
}
protected static Glyph getGlyph(FontAwesome5.Glyph glyphName) {
@@ -16,6 +16,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -24,6 +25,8 @@ import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.URDecoder;
import com.sparrowwallet.hummingbird.registry.pathcomponent.IndexPathComponent;
import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
@@ -53,6 +56,7 @@ import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@SuppressWarnings("deprecation")
@@ -169,6 +173,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
@@ -206,6 +211,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result));
} else {
result = new Result(new URException(urResult.error));
}
@@ -259,7 +265,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
DeterministicSeed seed;
try {
extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey);
result = new Result(extendedKey, null);
return;
} catch(Exception e) {
//Ignore, not a valid xpub
@@ -350,6 +356,17 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Ignore, not parseable as a CompactSeedQR
}
try {
List<String> words = Arrays.asList(qrtext.split(" "));
if(words.size() == 12 || words.size() == 15 || words.size() == 18 || words.size() == 21 || words.size() == 24) {
Bip39MnemonicCode.INSTANCE.check(words);
result = new Result(new DeterministicSeed(words, null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39));
return;
}
} catch(Exception e) {
//Ignore, not parseable as BIP39 seed words
}
result = new Result(qrtext);
}
}
@@ -408,7 +425,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
return new Result(extendedKey);
return new Result(extendedKey, cryptoHDKey.getName());
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
@@ -466,7 +483,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoHDKey.getOrigin() != null) {
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened());
if(lastComponent instanceof IndexPathComponent indexPathComponent) {
lastChild = new ChildNumber(indexPathComponent.getIndex(), indexPathComponent.isHardened());
}
depth = cryptoHDKey.getOrigin().getComponents().size();
}
if(cryptoHDKey.getParentFingerprint() != null) {
@@ -484,18 +503,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoOutput.getMultiKey() != null) {
MultiKey multiKey = cryptoOutput.getMultiKey();
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
Map<ExtendedKey, String> extendedPublicKeyLabels = new LinkedHashMap<>();
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
extendedPublicKeys.put(extendedKey, keyDerivation);
if(cryptoHDKey.getName() != null) {
extendedPublicKeyLabels.put(extendedKey, cryptoHDKey.getName());
}
}
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys);
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys, new LinkedHashMap<>(), extendedPublicKeyLabels);
} else if(cryptoOutput.getEcKey() != null) {
throw new IllegalArgumentException("EC keys are currently unsupported");
} else if(cryptoOutput.getHdKey() != null) {
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
return new OutputDescriptor(scriptType, extendedKey, keyDerivation);
return new OutputDescriptor(scriptType, extendedKey, keyDerivation, cryptoOutput.getHdKey().getName());
}
throw new IllegalStateException("CryptoOutput did not contain sufficient information");
@@ -542,7 +565,13 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
if(cryptoKeypath != null) {
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), cryptoKeypath.getPath());
if(!cryptoKeypath.getComponents().stream().allMatch(pathComponent -> pathComponent instanceof IndexPathComponent)) {
throw new IllegalArgumentException("Only indexed derivation path components are supported");
}
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), KeyDerivation.writePath(path));
}
return null;
@@ -656,6 +685,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final PSBT psbt;
public final BitcoinURI uri;
public final ExtendedKey extendedKey;
public final String extendedKeyName;
public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets;
public final DeterministicSeed seed;
@@ -667,6 +697,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -679,6 +710,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = psbt;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -691,6 +723,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = uri;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -703,6 +736,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -710,11 +744,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.exception = null;
}
public Result(ExtendedKey extendedKey) {
public Result(ExtendedKey extendedKey, String name) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = extendedKey;
this.extendedKeyName = name;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -727,6 +762,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = outputDescriptor;
this.wallets = null;
this.seed = null;
@@ -739,6 +775,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = wallets;
this.seed = null;
@@ -751,6 +788,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = seed;
@@ -763,6 +801,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -775,6 +814,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -58,6 +58,8 @@ public class ScriptArea extends CodeArea {
append("<signature" + signatureCount++ + ">", "script-signature");
} else if(chunk.isPubKey()) {
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
} else if(chunk.isString()) {
append(chunk.toString(), "script-other");
} else if(chunk.isScript()) {
Script nestedScript = chunk.getScript();
if (nestedScript.equals(redeemScript)) {
@@ -1,6 +1,8 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.wallet.*;
@@ -21,6 +23,7 @@ import tornadofx.control.Form;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public class SearchWalletDialog extends Dialog<Entry> {
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
@@ -36,13 +39,16 @@ public class SearchWalletDialog extends Dialog<Entry> {
throw new IllegalArgumentException("No wallets selected to search");
}
boolean showWallet = walletForms.stream().map(WalletForm::getMasterWallet).distinct().limit(2).count() > 1;
boolean showAccount = walletForms.stream().anyMatch(walletForm -> !walletForm.getWallet().isMasterWallet() || !walletForm.getNestedWalletForms().isEmpty());
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Search Wallet");
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet");
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if(!image.isError()) {
@@ -69,11 +75,9 @@ public class SearchWalletDialog extends Dialog<Entry> {
fieldset.getChildren().addAll(searchField);
form.getChildren().add(fieldset);
boolean showWallet = walletForms.size() > 1 || walletForms.stream().anyMatch(walletForm -> !walletForm.getNestedWalletForms().isEmpty());
results = new CoinTreeTable();
results.setShowRoot(false);
results.setPrefWidth(showWallet ? 950 : 850);
results.setPrefWidth(showWallet || showAccount ? 950 : 850);
results.setUnitFormat(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results"));
@@ -81,11 +85,19 @@ public class SearchWalletDialog extends Dialog<Entry> {
if(showWallet) {
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getMasterName());
});
results.getColumns().add(walletColumn);
}
if(showAccount) {
TreeTableColumn<Entry, String> accountColumn = new TreeTableColumn<>("Account");
accountColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
});
results.getColumns().add(accountColumn);
}
TreeTableColumn<Entry, String> typeColumn = new TreeTableColumn<>("Type");
typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType());
@@ -135,25 +147,22 @@ public class SearchWalletDialog extends Dialog<Entry> {
});
search.textProperty().addListener((observable, oldValue, newValue) -> {
searchWallet(newValue.toLowerCase(Locale.ROOT));
searchWallets(newValue.toLowerCase(Locale.ROOT));
});
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(search::requestFocus);
}
private void searchWallet(String searchText) {
private void searchWallets(String searchText) {
List<Entry> matchingEntries = new ArrayList<>();
if(!searchText.isEmpty()) {
Long searchValue = null;
try {
searchValue = Math.abs(Long.parseLong(searchText));
} catch(NumberFormatException e) {
//ignore
}
Long searchValue = getSearchValue(searchText);
Address searchAddress = getSearchAddress(searchText);
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
@@ -161,7 +170,8 @@ public class SearchWalletDialog extends Dialog<Entry> {
if(entry instanceof TransactionEntry transactionEntry) {
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue)) {
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue) ||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
matchingEntries.add(entry);
}
}
@@ -213,6 +223,22 @@ public class SearchWalletDialog extends Dialog<Entry> {
results.setRoot(rootItem);
}
private Long getSearchValue(String searchText) {
try {
return Math.abs(Long.parseLong(searchText));
} catch(NumberFormatException e) {
return null;
}
}
private Address getSearchAddress(String searchText) {
try {
return Address.fromString(searchText);
} catch(InvalidAddressException e) {
return null;
}
}
private static class SearchWalletEntry extends Entry {
public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
super(wallet, wallet.getName(), entries);
@@ -0,0 +1,91 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.List;
public class SeedEntryDialog extends Dialog<List<String>> {
private final MnemonicKeystoreEntryPane keystorePane;
public SeedEntryDialog(String name, int numWords) {
final DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
int lines = numWords / 3;
int height = lines * 40;
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.getStyleClass().add("edge-to-edge");
scrollPane.setPrefHeight(104 + height);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
Accordion keystoreAccordion = new Accordion();
scrollPane.setContent(keystoreAccordion);
keystorePane = new MnemonicKeystoreEntryPane(name, numWords);
keystorePane.setAnimated(false);
keystoreAccordion.getPanes().add(keystorePane);
stackPane.getChildren().addAll(anchorPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
okButton.disableProperty().bind(keystorePane.validProperty().not());
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate New", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(generateButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? keystorePane.wordEntriesProperty.get() : null;
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(180 + height);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
}
private class MnemonicGridDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
keystorePane.generateNew();
});
button = generateButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
}
public boolean isGenerated() {
return keystorePane.isGenerated();
}
}
@@ -81,6 +81,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
setResizable(true);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
@@ -11,10 +11,13 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.Optional;
public class TextAreaDialog extends Dialog<String> {
@@ -22,6 +25,8 @@ public class TextAreaDialog extends Dialog<String> {
private final TextArea textArea;
private final String defaultValue;
private final String fileName;
private final byte[] fileBytes;
public TextAreaDialog() {
this("");
@@ -31,7 +36,11 @@ public class TextAreaDialog extends Dialog<String> {
this(defaultValue, true);
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
public TextAreaDialog(String defaultValue, boolean editable) {
this(defaultValue, editable, null, null);
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable, @NamedArg("fileName") String fileName, @NamedArg("fileBytes") byte[] fileBytes) {
final DialogPane dialogPane = new TextAreaDialogPane();
setDialogPane(dialogPane);
@@ -48,6 +57,8 @@ public class TextAreaDialog extends Dialog<String> {
HBox.setHgrow(this.textArea, Priority.ALWAYS);
this.defaultValue = defaultValue;
this.fileName = fileName;
this.fileBytes = fileBytes;
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
@@ -60,6 +71,9 @@ public class TextAreaDialog extends Dialog<String> {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(scanButtonType);
} else if(fileBytes != null) {
final ButtonType saveButtonType = new javafx.scene.control.ButtonType("Save File...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(saveButtonType);
}
Platform.runLater(textArea::requestFocus);
@@ -71,6 +85,7 @@ public class TextAreaDialog extends Dialog<String> {
dialogPane.setPrefWidth(700);
dialogPane.setPrefHeight(400);
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
}
@@ -86,7 +101,7 @@ public class TextAreaDialog extends Dialog<String> {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Scan QR")) {
Button scanButton = new Button(buttonType.getText());
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
@@ -95,6 +110,7 @@ public class TextAreaDialog extends Dialog<String> {
ButtonBar.setButtonData(scanButton, buttonData);
scanButton.setOnAction(event -> {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
@@ -118,6 +134,38 @@ public class TextAreaDialog extends Dialog<String> {
});
button = scanButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Save File...")) {
Button saveButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(saveButton, buttonData);
saveButton.setOnAction(event -> {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save File");
if(fileName != null) {
fileChooser.setInitialFileName(fileName);
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try {
try(OutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(fileBytes);
outputStream.flush();
}
} catch(IOException e) {
log.error("Error saving file", e);
AppServices.showErrorDialog("Error saving file", "Cannot write to " + file.getAbsolutePath());
}
}
});
button = saveButton;
} else {
button = super.createButton(buttonType);
}
@@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TextfieldDialog extends Dialog<String> {
private static final Logger log = LoggerFactory.getLogger(TextAreaDialog.class);
private final TextField textField;
private final String defaultValue;
public TextfieldDialog() {
this("");
}
public TextfieldDialog(String defaultValue) {
this(defaultValue, true);
}
public TextfieldDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
final DialogPane dialogPane = getDialogPane();
setDialogPane(dialogPane);
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
HBox hbox = new HBox();
this.textField = new TextField(defaultValue);
this.textField.setMaxWidth(Double.MAX_VALUE);
this.textField.setEditable(editable);
hbox.getChildren().add(textField);
HBox.setHgrow(this.textField, Priority.ALWAYS);
this.defaultValue = defaultValue;
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getStyleClass().add("text-input-dialog");
dialogPane.getButtonTypes().add(ButtonType.OK);
if(editable) {
dialogPane.getButtonTypes().add(ButtonType.CANCEL);
}
Platform.runLater(textField::requestFocus);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? textField.getText() : null;
});
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(230);
AppServices.moveToActiveWindowScreen(this);
}
public final TextField getEditor() {
return textField;
}
public final String getDefaultValue() {
return defaultValue;
}
}
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.geometry.Insets;
@@ -106,13 +108,17 @@ public class TitledDescriptionPane extends TitledPane {
protected void setDescription(String text) {
descriptionLabel.getStyleClass().remove("description-error");
descriptionLabel.getStyleClass().add("description-label");
if(!descriptionLabel.getStyleClass().contains("description-label")) {
descriptionLabel.getStyleClass().add("description-label");
}
descriptionLabel.setText(text);
}
protected void setError(String title, String detail) {
descriptionLabel.getStyleClass().remove("description-label");
descriptionLabel.getStyleClass().add("description-error");
if(!descriptionLabel.getStyleClass().contains("description-error")) {
descriptionLabel.getStyleClass().add("description-error");
}
descriptionLabel.setText(title);
if(detail != null && !detail.isEmpty()) {
setContent(getContentBox(detail));
@@ -159,4 +165,17 @@ public class TitledDescriptionPane extends TitledPane {
}
});
}
protected static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
if(wallet == null || requiredDerivation == null) {
return 0;
}
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
if(account < 0) {
account = 0;
}
return account;
}
}
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.Node;
@@ -33,15 +34,15 @@ public class TorStatusLabel extends Label {
}
public void update() {
boolean proxyInUse = AppServices.isUsingProxy();
boolean internal = AppServices.isTorRunning();
if(!proxyInUse || internal) {
if(!Config.get().isUseProxy()) {
torConnectionTest.cancel();
if(internal) {
if(AppServices.isTorRunning()) {
setTooltip(new Tooltip("Internal Tor proxy enabled"));
}
} else if(!torConnectionTest.isRunning()) {
if(torConnectionTest.getState() == Worker.State.CANCELLED || torConnectionTest.getState() == Worker.State.FAILED) {
torConnectionTest.reset();
}
torConnectionTest.setPeriod(Duration.seconds(20.0));
torConnectionTest.setBackoffStrategy(null);
torConnectionTest.setOnSucceeded(workerStateEvent -> {
@@ -72,6 +72,7 @@ public class TransactionDiagram extends GridPane {
private WalletTransaction walletTx;
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<TransactionDiagramLabel> labelProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
private boolean expanded;
private TransactionDiagram expandedDiagram;
@@ -154,6 +155,10 @@ public class TransactionDiagram extends GridPane {
updateDerivedDiagram(expandedDiagram);
}
}
if(getLabel() != null) {
getLabel().update(this);
}
}
public void update(String message) {
@@ -534,8 +539,9 @@ public class TransactionDiagram extends GridPane {
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
}
private String getSatsValue(long amount) {
return String.format(Locale.ENGLISH, "%,d", amount);
String getSatsValue(long amount) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.formatSatsValue(amount);
}
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -636,7 +642,7 @@ public class TransactionDiagram extends GridPane {
double width = 140.0;
long sum = walletTx.getTotal();
List<Long> values = walletTx.getTransaction().getOutputs().stream().map(TransactionOutput::getValue).collect(Collectors.toList());
List<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
values.add(walletTx.getFee());
int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
for(int i = 1; i <= numOutputs; i++) {
@@ -691,7 +697,8 @@ public class TransactionDiagram extends GridPane {
Wallet toBip47Wallet = getBip47SendWallet(payment);
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ (isDuplicateAddress(payment) ? " (Duplicate)" : ""));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
@@ -922,12 +929,12 @@ public class TransactionDiagram extends GridPane {
}
if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) {
return "Whirlpool Fee";
return "Whirlpool fee";
} else if(walletTx.isPremixSend(payment)) {
int premixIndex = getOutputIndex(payment.getAddress(), payment.getAmount()) - 2;
return "Premix #" + premixIndex;
} else if(walletTx.isBadbankSend(payment)) {
return "Badbank Change";
return "Badbank change";
}
return null;
@@ -937,7 +944,7 @@ public class TransactionDiagram extends GridPane {
return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow();
}
private Wallet getToWallet(Payment payment) {
Wallet getToWallet(Payment payment) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet != walletTx.getWallet() && openWallet.isValid()) {
WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress());
@@ -984,11 +991,17 @@ public class TransactionDiagram extends GridPane {
return ((AdditionalPayment)payment).getOutputGlyph(this);
} else if(getToWallet(payment) != null) {
return getDepositGlyph();
} else if(isDuplicateAddress(payment)) {
return getPaymentWarningGlyph();
}
return getPaymentGlyph();
}
private boolean isDuplicateAddress(Payment payment) {
return walletTx.getPayments().stream().filter(p -> payment != p).anyMatch(p -> payment.getAddress() != null && payment.getAddress().equals(p.getAddress()));
}
public static Glyph getExcludeGlyph() {
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
excludeGlyph.getStyleClass().add("exclude-utxo");
@@ -1003,6 +1016,13 @@ public class TransactionDiagram extends GridPane {
return paymentGlyph;
}
public static Glyph getPaymentWarningGlyph() {
Glyph paymentWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
paymentWarningGlyph.getStyleClass().add("payment-warning-icon");
paymentWarningGlyph.setFontSize(12);
return paymentWarningGlyph;
}
public static Glyph getConsolidationGlyph() {
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
consolidationGlyph.getStyleClass().add("consolidation-icon");
@@ -1077,7 +1097,7 @@ public class TransactionDiagram extends GridPane {
return changeReplaceGlyph;
}
private Glyph getFeeGlyph() {
public Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
feeGlyph.getStyleClass().add("fee-icon");
feeGlyph.setFontSize(12);
@@ -1161,6 +1181,10 @@ public class TransactionDiagram extends GridPane {
}
}
public WalletTransaction getWalletTransaction() {
return walletTx;
}
public boolean isFinal() {
return finalProperty.get();
}
@@ -1173,6 +1197,18 @@ public class TransactionDiagram extends GridPane {
this.finalProperty.set(isFinal);
}
public TransactionDiagramLabel getLabel() {
return labelProperty.get();
}
public ObjectProperty<TransactionDiagramLabel> labelProperty() {
return labelProperty;
}
public void setLabelProperty(TransactionDiagramLabel label) {
this.labelProperty.set(label);
}
public OptimizationStrategy getOptimizationStrategy() {
return optimizationStrategyProperty.get();
}
@@ -1300,7 +1336,7 @@ public class TransactionDiagram extends GridPane {
}
}
private static class LabelContextMenu extends ContextMenu {
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
@@ -1316,6 +1352,7 @@ public class TransactionDiagram extends GridPane {
showAddress.setOnAction(event -> {
hide();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
qrDisplayDialog.initOwner(TransactionDiagram.this.getScene().getWindow());
qrDisplayDialog.showAndWait();
});
getItems().add(showAddress);
@@ -0,0 +1,268 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.text.Font;
import org.controlsfx.glyphfont.Glyph;
import java.util.*;
import java.util.stream.Collectors;
public class TransactionDiagramLabel extends HBox {
private final List<HBox> outputs = new ArrayList<>();
private final Button left;
private final Button right;
private final IntegerProperty displayedIndex = new SimpleIntegerProperty(-1);
public TransactionDiagramLabel() {
setSpacing(5);
setAlignment(Pos.CENTER_RIGHT);
left = new Button("");
left.setGraphic(getLeftGlyph());
left.setOnAction(event -> {
int index = displayedIndex.get();
if(index > 0) {
index--;
}
displayedIndex.set(index);
});
right = new Button("");
right.setGraphic(getRightGlyph());
right.setOnAction(event -> {
int index = displayedIndex.get();
if(index < outputs.size() - 1) {
index++;
}
displayedIndex.set(index);
});
displayedIndex.addListener((observable, oldValue, newValue) -> {
left.setDisable(newValue.intValue() <= 0);
right.setDisable(newValue.intValue() < 0 || newValue.intValue() >= outputs.size() - 1);
if(oldValue.intValue() >= 0 && oldValue.intValue() < outputs.size()) {
outputs.get(oldValue.intValue()).setVisible(false);
}
if(newValue.intValue() >= 0 && newValue.intValue() < outputs.size()) {
outputs.get(newValue.intValue()).setVisible(true);
}
});
}
public void update(TransactionDiagram transactionDiagram) {
getChildren().clear();
outputs.clear();
displayedIndex.set(-1);
double maxWidth = getMaxWidth();
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
List<OutputLabel> outputLabels = new ArrayList<>();
List<Payment> premixOutputs = walletTx.getPayments().stream().filter(walletTx::isPremixSend).collect(Collectors.toList());
if(!premixOutputs.isEmpty()) {
OutputLabel premixOutputLabel = getPremixOutputLabel(transactionDiagram, premixOutputs);
if(premixOutputLabel != null) {
outputLabels.add(premixOutputLabel);
}
Optional<Payment> optWhirlpoolFee = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.WHIRLPOOL_FEE).findFirst();
if(optWhirlpoolFee.isPresent()) {
OutputLabel whirlpoolFeeOutputLabel = getWhirlpoolFeeOutputLabel(transactionDiagram, optWhirlpoolFee.get(), premixOutputs);
outputLabels.add(whirlpoolFeeOutputLabel);
}
List<Payment> badbankOutputs = walletTx.getPayments().stream().filter(walletTx::isBadbankSend).collect(Collectors.toList());
List<OutputLabel> badbankOutputLabels = badbankOutputs.stream().map(payment -> getBadbankOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
outputLabels.addAll(badbankOutputLabels);
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_PREMIX && walletTx.getPayments().stream().anyMatch(walletTx::isPostmixSend)) {
OutputLabel mixOutputLabel = getMixOutputLabel(transactionDiagram, walletTx.getPayments());
if(mixOutputLabel != null) {
outputLabels.add(mixOutputLabel);
}
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
if(remixOutputLabel != null) {
outputLabels.add(remixOutputLabel);
}
} else {
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
}
outputLabels.addAll(paymentLabels);
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
outputLabels.addAll(mixes.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()));
}
Map<WalletNode, Long> changeMap = walletTx.getChangeMap();
outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList()));
OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram);
if(feeOutputLabel != null) {
outputLabels.add(feeOutputLabel);
}
for(OutputLabel outputLabel : outputLabels) {
maxWidth = Math.max(maxWidth, outputLabel.width);
outputs.add(outputLabel.hBox);
getChildren().add(outputLabel.hBox);
}
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER_RIGHT);
buttonBox.getChildren().addAll(left, right);
getChildren().add(buttonBox);
setMaxWidth(maxWidth);
setPrefWidth(maxWidth);
if(outputLabels.size() > 0) {
displayedIndex.set(0);
}
}
private OutputLabel getPremixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> premixOutputs) {
if(premixOutputs.isEmpty()) {
return null;
}
Payment premixOutput = premixOutputs.get(0);
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = transactionDiagram.getOutputGlyph(premixOutput);
String text;
if(premixOutputs.size() == 1) {
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
} else {
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
}
return getOutputLabel(glyph, text);
}
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
Glyph glyph = transactionDiagram.getOutputGlyph(payment);
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getWhirlpoolFeeOutputLabel(TransactionDiagram transactionDiagram, Payment whirlpoolFee, List<Payment> premixOutputs) {
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
Glyph glyph = transactionDiagram.getOutputGlyph(whirlpoolFee);
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
return getOutputLabel(glyph, text);
}
private OutputLabel getMixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> mixOutputs) {
if(mixOutputs.isEmpty()) {
return null;
}
Payment remixOutput = mixOutputs.get(0);
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = TransactionDiagram.getPremixGlyph();
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
return getOutputLabel(glyph, text);
}
private OutputLabel getRemixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> remixOutputs) {
if(remixOutputs.isEmpty()) {
return null;
}
Payment remixOutput = remixOutputs.get(0);
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = TransactionDiagram.getPremixGlyph();
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Wallet toWallet = transactionDiagram.getToWallet(payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Glyph glyph = transactionDiagram.getOutputGlyph(payment);
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Map.Entry<WalletNode, Long> changeEntry) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Glyph glyph = TransactionDiagram.getChangeGlyph();
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
if(walletTx.getFee() < 0) {
return null;
}
Glyph glyph = transactionDiagram.getFeeGlyph();
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)";
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(Glyph glyph, String text) {
Label icon = new Label();
icon.setMinWidth(15);
glyph.setFontSize(12);
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setFont(Font.font("Roboto Mono Italic", 13));
label.setText(text);
HBox output = new HBox(5);
output.setAlignment(Pos.CENTER);
output.managedProperty().bind(output.visibleProperty());
output.setVisible(false);
output.getChildren().addAll(icon, label);
double lineWidth = TextUtils.computeTextWidth(label.getFont(), label.getText(), 0.0D) + 2 + getSpacing() + icon.getMinWidth() + 60;
return new OutputLabel(output, lineWidth, text);
}
public static Glyph getLeftGlyph() {
Glyph caretLeftGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_LEFT);
caretLeftGlyph.getStyleClass().add("label-left-icon");
caretLeftGlyph.setFontSize(15);
return caretLeftGlyph;
}
public static Glyph getRightGlyph() {
Glyph caretRightGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_RIGHT);
caretRightGlyph.getStyleClass().add("label-right-icon");
caretRightGlyph.setFontSize(15);
return caretRightGlyph;
}
private record OutputLabel(HBox hBox, double width, String text) {}
}
@@ -50,8 +50,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
getSortOrder().add(dateCol);
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateAll(WalletTransactionsEntry rootEntry) {
@@ -61,16 +60,13 @@ public class TransactionsTreeTable extends CoinTreeTable {
setRoot(rootItem);
rootItem.setExpanded(true);
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
TreeTableColumn<Entry, ?> dateCol = getColumns().get(0);
getSortOrder().add(dateCol);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
}
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateHistory() {
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
sort();
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateLabel(Entry entry) {
@@ -63,6 +63,8 @@ public class TrayManager {
miStage.setFont(Font.decode(null).deriveFont(Font.BOLD));
miStage.addActionListener(e -> Platform.runLater(() -> {
stage.show();
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
EventQueue.invokeLater(() -> {
popupMenu.remove(miStage);
@@ -0,0 +1,46 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.control.TreeTableColumn;
public class TreeTableColumnConfig {
private final int index;
private final Integer width;
private final TreeTableColumn.SortType sortType;
public TreeTableColumnConfig(int index, Integer width, TreeTableColumn.SortType sortType) {
this.index = index;
this.width = width;
this.sortType = sortType;
}
public TreeTableColumnConfig(int index, String width, String sortType) {
this.index = index;
this.width = width.isEmpty() ? null : Integer.valueOf(width, 10);
this.sortType = sortType.isEmpty() ? null : TreeTableColumn.SortType.valueOf(sortType);
}
public int getIndex() {
return index;
}
public Integer getWidth() {
return width;
}
public TreeTableColumn.SortType getSortType() {
return sortType;
}
public String toString() {
return index + "-" + (width == null ? "" : width) + "-" + (sortType == null ? "" : sortType);
}
public static TreeTableColumnConfig fromString(String columnConfig) {
String[] parts = columnConfig.split("-", 3);
if(parts.length == 3) {
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), parts[1], parts[2]);
}
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), (Integer)null, null);
}
}
@@ -0,0 +1,42 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.TreeTableColumn;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
public class TreeTableConfig {
private final List<TreeTableColumnConfig> columnConfigs;
public TreeTableConfig(CoinTreeTable treeTable) {
columnConfigs = new ArrayList<>();
TreeTableColumn<Entry, ?> sortColumn = treeTable.getSortOrder().isEmpty() ? null : treeTable.getSortOrder().get(0);
for(int i = 0; i < treeTable.getColumns().size(); i++) {
TreeTableColumn<Entry, ?> column = treeTable.getColumns().get(i);
//TODO: Support column widths
columnConfigs.add(new TreeTableColumnConfig(i, null, sortColumn == column ? column.getSortType() : null));
}
}
public TreeTableConfig(List<TreeTableColumnConfig> columnConfigs) {
this.columnConfigs = columnConfigs;
}
public String toString() {
StringJoiner joiner = new StringJoiner("|");
columnConfigs.stream().forEach(col -> joiner.add(col.toString()));
return joiner.toString();
}
public static TreeTableConfig fromString(String tableConfig) {
List<TreeTableColumnConfig> columnConfigs = new ArrayList<>();
String[] parts = tableConfig.split("\\|");
for(String part : parts) {
columnConfigs.add(TreeTableColumnConfig.fromString(part));
}
return new TreeTableConfig(columnConfigs);
}
}
@@ -83,8 +83,7 @@ public class UtxosTreeTable extends CoinTreeTable {
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
getSortOrder().add(amountCol);
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
}
@@ -96,17 +95,14 @@ public class UtxosTreeTable extends CoinTreeTable {
setRoot(rootItem);
rootItem.setExpanded(true);
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
TreeTableColumn<Entry, ?> amountCol = getColumns().get(getColumns().size() - 1);
getSortOrder().add(amountCol);
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
}
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
}
public void updateHistory() {
//Utxo entries should have already been updated, so only a resort required
if(!getRoot().getChildren().isEmpty()) {
sort();
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
}
}
@@ -21,12 +21,12 @@ import java.util.Date;
public class WalletBirthDateDialog extends Dialog<Date> {
private final DatePicker birthDatePicker;
public WalletBirthDateDialog(Date birthDate) {
public WalletBirthDateDialog(Date birthDate, boolean singleAddress) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first wallet transaction:");
setTitle(singleAddress ? "Address Scan Start Date" : "Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first " + (singleAddress ? "" : "wallet") + " transaction:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(420);
@@ -58,7 +58,7 @@ public class WalletBirthDateDialog extends Dialog<Date> {
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType(singleAddress ? "Scan Address" : "Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> birthDatePicker.getValue() == null, birthDatePicker.valueProperty());
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.io.*;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
@@ -17,7 +18,9 @@ import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> {
private Wallet wallet;
public WalletExportDialog(Wallet wallet) {
public WalletExportDialog(WalletForm walletForm) {
this.wallet = walletForm.getWallet();
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -42,9 +45,10 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow());
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(),
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -64,6 +68,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(480);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
@@ -0,0 +1,215 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImageUtils;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Pos;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle;
import org.controlsfx.glyphfont.Glyph;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
public class WalletIcon extends StackPane {
public static final String PROTOCOL = "walleticon";
private static final String QUERY = "icon";
public static final int WIDTH = 15;
public static final int HEIGHT = 15;
public static final int SAVE_WIDTH = WIDTH * 2;
public static final int SAVE_HEIGHT = HEIGHT * 2;
private final Storage storage;
private final ObjectProperty<Wallet> walletProperty = new SimpleObjectProperty<>();
private final Keystore keystore;
private final Glyph fallbackIcon;
public WalletIcon(Storage storage, Wallet wallet) {
this(storage, wallet, null, null);
}
public WalletIcon(Storage storage, Wallet wallet, Keystore keystore, Glyph fallbackIcon) {
super();
this.storage = storage;
this.keystore = keystore;
this.fallbackIcon = fallbackIcon;
setPrefSize(WIDTH, HEIGHT);
walletProperty.addListener((observable, oldValue, newValue) -> {
refresh();
});
walletProperty.set(wallet);
}
public void refresh() {
Wallet wallet = getWallet();
getChildren().clear();
if(wallet.getWalletConfig() != null && wallet.getWalletConfig().getIconData() != null) {
String walletId = storage.getWalletId(wallet);
if(AppServices.get().getWallet(walletId) != null) {
addWalletIcon(walletId);
} else {
Platform.runLater(() -> addWalletIcon(walletId));
}
} else if(this.keystore != null || wallet.getKeystores().size() == 1) {
Keystore keystore = this.keystore == null ? wallet.getKeystores().get(0) : this.keystore;
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getSource() == KeystoreSource.HW_AIRGAPPED) {
WalletModel walletModel = keystore.getWalletModel();
SVGImage svgImage;
if(Config.get().getTheme() == Theme.DARK) {
svgImage = loadSVGImage("/image/" + walletModel.getType() + "-icon-invert.svg");
} else {
svgImage = loadSVGImage("/image/" + walletModel.getType() + "-icon.svg");
}
if(svgImage != null) {
getChildren().add(svgImage);
return;
}
Image image = null;
if(Config.get().getTheme() == Theme.DARK) {
image = loadImage("image/" + walletModel.getType() + "-icon-invert.png");
}
if(image == null) {
image = loadImage("image/" + walletModel.getType() + "-icon.png");
}
if(image == null) {
image = loadImage("image/" + walletModel.getType() + ".png");
}
if(image != null && !image.isError()) {
ImageView imageView = new ImageView(image);
getChildren().add(imageView);
}
}
}
if(getChildren().isEmpty()) {
Glyph glyph = fallbackIcon;
if(glyph == null) {
glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
}
glyph.setFontSize(10.0);
getChildren().add(glyph);
}
}
private SVGImage loadSVGImage(String imageName) {
try {
URL url = AppServices.class.getResource(imageName);
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
//ignore
}
return null;
}
private Image loadImage(String imageName) {
try {
return new Image(imageName, 15, 15, true, true);
} catch(Exception e) {
//ignore
}
return null;
}
private void addWalletIcon(String walletId) {
Image image = new Image(PROTOCOL + ":" + walletId + "?" + QUERY, WIDTH, HEIGHT, true, false);
getChildren().clear();
Circle circle = new Circle(getPrefWidth() / 2,getPrefHeight() / 2,getPrefWidth() / 2);
circle.setFill(new ImagePattern(image));
getChildren().add(circle);
}
public boolean addFailure() {
if(getChildren().stream().noneMatch(node -> node.getStyleClass().contains("failure"))) {
Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
failGlyph.setFontSize(10);
failGlyph.getStyleClass().add("failure");
getChildren().add(failGlyph);
StackPane.setAlignment(failGlyph, Pos.TOP_RIGHT);
failGlyph.setTranslateX(5);
failGlyph.setTranslateY(-4);
return true;
}
return false;
}
public void removeFailure() {
getChildren().removeIf(node -> node.getStyleClass().contains("failure"));
}
public Wallet getWallet() {
return walletProperty.get();
}
public ObjectProperty<Wallet> walletProperty() {
return walletProperty;
}
public void setWallet(Wallet wallet) {
this.walletProperty.set(wallet);
}
public static class WalletIconStreamHandler extends URLStreamHandler {
@Override
protected URLConnection openConnection(URL url) throws IOException {
return new URLConnection(url) {
@Override
public void connect() throws IOException {
//Nothing required
}
@Override
public InputStream getInputStream() throws IOException {
String walletId = url.getPath();
String query = url.getQuery();
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet == null) {
throw new IOException("Cannot find wallet for wallet id " + walletId);
}
if(query.startsWith(QUERY)) {
if(wallet.getWalletConfig() == null || wallet.getWalletConfig().getIconData() == null) {
throw new IOException("No icon data for " + walletId);
}
ByteArrayInputStream bais = new ByteArrayInputStream(wallet.getWalletConfig().getIconData());
if(query.endsWith("@2x")) {
return bais;
} else {
return ImageUtils.resize(bais, WalletIcon.WIDTH, WalletIcon.HEIGHT);
}
}
throw new MalformedURLException("Cannot load url " + url);
}
};
}
}
}
@@ -9,6 +9,7 @@ import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
@@ -16,6 +17,7 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -24,7 +26,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
private final Accordion importAccordion;
private final Button scanButton;
public WalletImportDialog() {
public WalletImportDialog(List<WalletForm> selectedWalletForms) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -49,7 +51,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux());
for(KeystoreFileImport importer : keystoreImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
@@ -57,7 +59,10 @@ public class WalletImportDialog extends Dialog<Wallet> {
}
}
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig()));
if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms));
}
for(WalletImport importer : walletImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
@@ -85,6 +90,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(600);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
@@ -34,9 +34,21 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
}
public WalletNameDialog(String initialName) {
this(initialName, false);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions) {
this(initialName, hasExistingTransactions, null);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate) {
this(initialName, hasExistingTransactions, startDate, false);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate, boolean rename) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
boolean requestBirthDate = !rename && (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
setTitle("Wallet Name");
dialogPane.setHeaderText("Enter a name for this wallet:");
@@ -91,6 +103,12 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
if(requestBirthDate) {
content.getChildren().add(existingBox);
if(hasExistingTransactions) {
existingCheck.setSelected(true);
}
if(startDate != null) {
existingPicker.setValue(startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
}
}
dialogPane.setContent(content);
@@ -107,16 +125,16 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType((rename ? "Rename" : "Create") + " Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
name.getText().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
name.getText().trim().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
okButton.disableProperty().bind(isInvalid);
name.setPromptText("Wallet Name");
Platform.runLater(name::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText(), existingPicker.getValue()) : null);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText().trim(), existingPicker.getValue()) : null);
}
public static class NameAndBirthDate {
@@ -0,0 +1,172 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.Function;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Side;
import javafx.scene.chart.NumberAxis;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class WalletSummaryDialog extends Dialog<Void> {
public WalletSummaryDialog(List<WalletForm> walletForms) {
if(walletForms.isEmpty()) {
throw new IllegalArgumentException("No wallets selected to summarize");
}
Wallet masterWallet = walletForms.get(0).getMasterWallet();
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/transactions.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Wallet Summary for " + masterWallet.getName());
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if(!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
HBox hBox = new HBox(40);
CoinTreeTable table = new CoinTreeTable();
TreeTableColumn<Entry, String> nameColumn = new TreeTableColumn<>("Wallet");
nameColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getLabel());
});
nameColumn.setCellFactory(p -> new LabelCell());
table.getColumns().add(nameColumn);
TreeTableColumn<Entry, Number> balanceColumn = new TreeTableColumn<>("Balance");
balanceColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
});
balanceColumn.setCellFactory(p -> new CoinCell());
table.getColumns().add(balanceColumn);
table.setUnitFormat(masterWallet);
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
TreeTableColumn<Entry, Number> fiatColumn = new TreeTableColumn<>(currencyRate.getCurrency().getSymbol());
fiatColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
});
fiatColumn.setCellFactory(p -> new FiatCell());
table.getColumns().add(fiatColumn);
table.setCurrencyRate(currencyRate);
}
SummaryEntry rootEntry = new SummaryEntry(walletForms);
TreeItem<Entry> rootItem = new TreeItem<>(rootEntry);
for(Entry childEntry : rootEntry.getChildren()) {
TreeItem<Entry> childItem = new TreeItem<>(childEntry);
rootItem.getChildren().add(childItem);
childItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)childEntry)));
}
table.setShowRoot(true);
table.setRoot(rootItem);
rootItem.setExpanded(true);
table.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
table.setPrefWidth(450);
VBox vBox = new VBox();
vBox.getChildren().add(table);
hBox.getChildren().add(vBox);
NumberAxis xAxis = new NumberAxis();
xAxis.setSide(Side.BOTTOM);
xAxis.setForceZeroInRange(false);
xAxis.setMinorTickVisible(false);
NumberAxis yAxis = new NumberAxis();
yAxis.setSide(Side.LEFT);
BalanceChart balanceChart = new BalanceChart(xAxis, yAxis);
balanceChart.initialize(new WalletTransactionsEntry(masterWallet, true));
balanceChart.setAnimated(false);
balanceChart.setLegendVisible(false);
balanceChart.setVerticalGridLinesVisible(false);
hBox.getChildren().add(balanceChart);
getDialogPane().setContent(hBox);
ButtonType okButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
}
public static class SummaryEntry extends Entry {
private SummaryEntry(List<WalletForm> walletForms) {
super(walletForms.get(0).getWallet(), walletForms.get(0).getWallet().getName(), walletForms.stream().map(WalletForm::getWalletTransactionsEntry).collect(Collectors.toList()));
}
@Override
public Long getValue() {
long value = 0;
for(Entry entry : getChildren()) {
value += entry.getValue();
}
return value;
}
@Override
public String getEntryType() {
return null;
}
@Override
public Function getWalletFunction() {
return Function.TRANSACTIONS;
}
}
public static class UnconfirmedEntry extends Entry {
private final WalletTransactionsEntry walletTransactionsEntry;
private UnconfirmedEntry(WalletTransactionsEntry walletTransactionsEntry) {
super(walletTransactionsEntry.getWallet(), "Unconfirmed", Collections.emptyList());
this.walletTransactionsEntry = walletTransactionsEntry;
}
@Override
public Long getValue() {
return walletTransactionsEntry.getMempoolBalance();
}
@Override
public String getEntryType() {
return null;
}
@Override
public Function getWalletFunction() {
return Function.TRANSACTIONS;
}
}
}
@@ -14,16 +14,22 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import net.sourceforge.zbar.ZBar;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private WebcamResolution resolution;
private WebcamDevice device;
private final WebcamListener listener;
@@ -137,6 +143,9 @@ public class WebcamService extends ScheduledService<Image> {
if(result == null) {
result = readQR(croppedImage);
}
if(result == null) {
result = readQR(invert(croppedImage));
}
if(result != null) {
resultProperty.set(result);
@@ -147,6 +156,13 @@ public class WebcamService extends ScheduledService<Image> {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
if(ZBar.isEnabled()) {
ZBar.Scan scan = ZBar.scan(bufferedImage);
if(scan != null) {
return new Result(scan.stringData(), scan.rawData(), new ResultPoint[0], BarcodeFormat.QR_CODE);
}
}
try {
return qrReader.decode(bitmap, Map.of(DecodeHintType.TRY_HARDER, Boolean.TRUE));
} catch(ReaderException e) {
@@ -175,6 +191,29 @@ public class WebcamService extends ScheduledService<Image> {
return new CroppedDimension(x, y, squareSize);
}
public BufferedImage invert(BufferedImage inImg) {
try {
int width = inImg.getWidth();
int height = inImg.getHeight();
BufferedImage outImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
WritableRaster outRaster = outImg.getRaster();
WritableRaster inRaster = inImg.getRaster();
for(int y = 0; y < height; y++) {
for(int x = 0; x < width; x++) {
for(int i = 0; i < outRaster.getNumBands(); i++) {
outRaster.setSample(x, y, i, 255 - inRaster.getSample(x, y, i));
}
}
}
return outImg;
} catch(Exception e) {
log.warn("Error inverting image", e);
return inImg;
}
}
public Result getResult() {
return resultProperty.get();
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.sparrow.net.TlsServerException;
public class ConnectionFailedEvent {
@@ -20,6 +21,12 @@ public class ConnectionFailedEvent {
Throwable cause = (exception.getCause() != null ? exception.getCause() : exception);
cause = (cause.getCause() != null ? cause.getCause() : cause);
if(cause instanceof JsonRpcException jsonRpcException && jsonRpcException.getErrorMessage() != null) {
return jsonRpcException.getErrorMessage().getMessage() +
(jsonRpcException.getErrorMessage().getData() != null ? " (" + jsonRpcException.getErrorMessage().getData().asText() + ")" : "");
}
String message = splitCamelCase(cause.getClass().getSimpleName().replace("Exception", "Error"));
return message + " (" + cause.getMessage() + ")";
}
@@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CormorantPruneStatusEvent extends CormorantStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd");
private final Wallet wallet;
private final Date scanDate;
private final Date pruneDate;
private final boolean legacyWalletExists;
public CormorantPruneStatusEvent(String status, Wallet wallet, Date scanDate, Date pruneDate, boolean legacyWalletExists) {
super(status);
this.wallet = wallet;
this.scanDate = scanDate;
this.pruneDate = pruneDate;
this.legacyWalletExists = legacyWalletExists;
}
@Override
public boolean isFor(Wallet wallet) {
return this.wallet == wallet;
}
public Wallet getWallet() {
return wallet;
}
public Date getScanDate() {
return scanDate;
}
public String getScanDateAsString() {
return DATE_FORMAT.format(scanDate);
}
public Date getPruneDate() {
return pruneDate;
}
public String getPruneDateAsString() {
return DATE_FORMAT.format(pruneDate);
}
public boolean legacyWalletExists() {
return legacyWalletExists;
}
}
@@ -0,0 +1,48 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.time.Duration;
import java.util.Set;
public class CormorantScanStatusEvent extends CormorantStatusEvent {
private final Set<Wallet> scanningWallets;
private final int progress;
private final Duration remainingDuration;
public CormorantScanStatusEvent(String status, Set<Wallet> scanningWallets, int progress, Duration remainingDuration) {
super(status);
this.scanningWallets = scanningWallets;
this.progress = progress;
this.remainingDuration = remainingDuration;
}
@Override
public boolean isFor(Wallet wallet) {
return scanningWallets.contains(wallet);
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Duration getRemaining() {
return remainingDuration;
}
public String getRemainingAsString() {
if(remainingDuration != null) {
if(progress < 30) {
return Math.round((double)remainingDuration.toSeconds() / 60) + "m";
} else {
return remainingDuration.toMinutesPart() + "m " + remainingDuration.toSecondsPart() + "s";
}
}
return "";
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public abstract class CormorantStatusEvent {
private final String status;
public CormorantStatusEvent(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public abstract boolean isFor(Wallet wallet);
}
@@ -0,0 +1,41 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CormorantSyncStatusEvent extends CormorantStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm");
private final int progress;
private final Date tip;
public CormorantSyncStatusEvent(String status, int progress, Date tip) {
super(status);
this.progress = progress;
this.tip = tip;
}
@Override
public boolean isFor(Wallet wallet) {
return true;
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Date getTip() {
return tip;
}
public String getTipAsString() {
return tip == null ? "" : DATE_FORMAT.format(tip);
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.address.Address;
public class DeviceAddressEvent {
private final Address address;
public DeviceAddressEvent(Address address) {
this.address = address;
}
public Address getAddress() {
return address;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
public class DeviceGetPrivateKeyEvent {
private final ECKey privateKey;
private final ScriptType scriptType;
public DeviceGetPrivateKeyEvent(ECKey privateKey, ScriptType scriptType) {
this.privateKey = privateKey;
this.scriptType = scriptType;
}
public ECKey getPrivateKey() {
return privateKey;
}
public ScriptType getScriptType() {
return scriptType;
}
}
@@ -0,0 +1,21 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class ExistingWalletImportedEvent {
private final String existingWalletId;
private final Wallet importedWallet;
public ExistingWalletImportedEvent(String existingWalletId, Wallet importedWallet) {
this.existingWalletId = existingWalletId;
this.importedWallet = importedWallet;
}
public String getExistingWalletId() {
return existingWalletId;
}
public Wallet getImportedWallet() {
return importedWallet;
}
}
@@ -5,20 +5,15 @@ import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Map;
import java.util.Set;
public class FeeRatesUpdatedEvent {
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
private final Set<MempoolRateSize> mempoolRateSizes;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
super(mempoolRateSizes);
this.targetBlockFeeRates = targetBlockFeeRates;
this.mempoolRateSizes = mempoolRateSizes;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
public class KeystoreExportEvent {
private final Keystore keystore;
public KeystoreExportEvent(Keystore keystore) {
this.keystore = keystore;
}
public Keystore getKeystore() {
return keystore;
}
}
@@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.event;
/**
* The event is posted when the first set of mempool entries (txid and vsizes) have been retrieved from the node.
* Cormorant only.
*/
public class MempoolEntriesInitializedEvent {
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Set;
public class MempoolRateSizesUpdatedEvent {
private final Set<MempoolRateSize> mempoolRateSizes;
public MempoolRateSizesUpdatedEvent(Set<MempoolRateSize> mempoolRateSizes) {
this.mempoolRateSizes = mempoolRateSizes;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -0,0 +1,24 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.io.Storage;
public class NewChildWalletSavedEvent {
private final Storage storage;
private final Wallet masterWallet;
private final Wallet childWallet;
public NewChildWalletSavedEvent(Storage storage, Wallet masterWallet, Wallet childWallet) {
this.storage = storage;
this.masterWallet = masterWallet;
this.childWallet = childWallet;
}
public String getMasterWalletId() {
return storage.getWalletId(masterWallet);
}
public Wallet getChildWallet() {
return childWallet;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.psbt.PSBT;
public class PSBTReorderedEvent {
private final PSBT psbt;
public PSBTReorderedEvent(PSBT psbt) {
this.psbt = psbt;
}
public PSBT getPsbt() {
return psbt;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import javafx.scene.image.Image;
public class PayNymImageLoadedEvent {
private final PaymentCode paymentCode;
private final Image image;
public PayNymImageLoadedEvent(PaymentCode paymentCode, Image image) {
this.paymentCode = paymentCode;
this.image = image;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
public Image getImage() {
return image;
}
}
@@ -2,6 +2,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.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -14,21 +15,23 @@ public class SpendUtxoEvent {
private final List<Payment> payments;
private final List<byte[]> opReturns;
private final Long fee;
private final boolean includeSpentMempoolOutputs;
private final boolean requireAllUtxos;
private final BlockTransaction replacedTransaction;
private final Pool pool;
private final PaymentCode paymentCode;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, null, false);
this(wallet, utxos, null, null, null, false, null);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean includeSpentMempoolOutputs) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.opReturns = opReturns;
this.fee = fee;
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
this.requireAllUtxos = requireAllUtxos;
this.replacedTransaction = replacedTransaction;
this.pool = null;
this.paymentCode = null;
}
@@ -39,7 +42,8 @@ public class SpendUtxoEvent {
this.payments = payments;
this.opReturns = opReturns;
this.fee = fee;
this.includeSpentMempoolOutputs = false;
this.requireAllUtxos = false;
this.replacedTransaction = null;
this.pool = pool;
this.paymentCode = null;
}
@@ -50,7 +54,8 @@ public class SpendUtxoEvent {
this.payments = payments;
this.opReturns = opReturns;
this.fee = null;
this.includeSpentMempoolOutputs = false;
this.requireAllUtxos = false;
this.replacedTransaction = null;
this.pool = null;
this.paymentCode = paymentCode;
}
@@ -75,8 +80,12 @@ public class SpendUtxoEvent {
return fee;
}
public boolean isIncludeSpentMempoolOutputs() {
return includeSpentMempoolOutputs;
public boolean isRequireAllUtxos() {
return requireAllUtxos;
}
public BlockTransaction getReplacedTransaction() {
return replacedTransaction;
}
public Pool getPool() {
@@ -1,13 +1,10 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.TorServerAlreadyBoundException;
public class TorFailedStatusEvent extends TorStatusEvent {
private final Throwable exception;
public TorFailedStatusEvent(Throwable exception) {
super("Tor failed to start: " + (exception instanceof TorServerAlreadyBoundException ? exception.getCause().getMessage() + " Is a Tor proxy already running?" :
(exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage())));
super("Tor failed to start: " + (exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage()));
this.exception = exception;
}

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