Compare commits

...
192 Commits
Author SHA1 Message Date
Craig Raw 6fc52fdc0e display effective fee rate next to transaction fee rate when constructing a cpfp tx 2024-01-18 09:53:44 +02:00
Craig Raw 1d2081d2a6 transaction tree label tweaks 2024-01-18 09:00:39 +02:00
Craig Raw 4d587cf776 update keystone logo 2024-01-18 08:35:01 +02:00
Craig Raw 14689dd256 add payment labels to transaction tabs for received outputs where available from open wallets 2024-01-17 14:43:24 +02:00
Craig Raw c8a5c1fb86 followup 2024-01-17 13:50:04 +02:00
Craig Raw 734818cd9e adjust transaction tree width for input labelling 2024-01-17 13:43:48 +02:00
Craig Raw 6aa5473b24 add rescan note to warning tooltip for large gap limits 2024-01-17 12:33:55 +02:00
Craig Raw 1b460533f5 followup to reducing server calls on loading transaction tab 2024-01-17 10:48:57 +02:00
Craig Raw ad1ecfb887 fix changing amount bitcoin unit on send tab with comma decimal separator 2024-01-16 12:19:56 +02:00
Craig Raw 6a5060c0c8 cormorant: support coinbase transactions 2024-01-15 17:20:44 +02:00
Craig Raw 02a0a3277b reduce server calls on opening a transaction tab by using open wallet history when performing spent output lookup 2024-01-12 15:35:26 +02:00
Craig Raw 57dba5d6ae improve input and output labels on transaction tree and detail panels 2024-01-12 12:10:38 +02:00
Craig Raw 540424a2e3 followup 2024-01-10 11:18:26 +02:00
craigrawandGitHub e1d9d54da3 Merge pull request #1204 from krzyczak/master
toggle bitcoin unit on clicking a bitcoin value label
2024-01-10 11:15:47 +02:00
Craig Raw 2b8a513adf support reading revised ur tags including v3 output descriptors 2024-01-10 11:05:03 +02:00
Craig Raw 20df1dbd8b fix typo 2024-01-09 11:59:19 +02:00
Craig Raw 04c8017bb5 increase payments tab header width on send tab 2024-01-09 11:58:38 +02:00
Craig Raw be0ac52a09 support creating wallets from descriptors containing master xprvs 2024-01-09 11:38:10 +02:00
Craig Raw 3162371838 fix order of outputs displayed in transaction diagram where there are multiple payments with the same address and amount 2024-01-08 12:12:13 +02:00
Craig Raw b5196d1ac2 encrypt electrum wallet exports including private keys where password is available 2024-01-08 09:16:55 +02:00
krzyczak 343cb2271c feat: Implement bitcoin unit toggle on bitcoin balance 2023-12-22 17:29:22 +00:00
Craig Raw 4feb4a3a79 add airgap vault to airgapped options (draft) 2023-12-06 08:55:09 +02:00
craigrawandGitHub 20b4d5a1b5 Merge pull request #1187 from Toporin/patch-satochip-error-0x9C01
satochip: flush card cache where cache memory is full
2023-12-01 09:12:58 +02:00
Craig Raw 06489d8339 cormorant: round up wallet range to avoid frequent rescans with a large gap limit 2023-12-01 09:09:28 +02:00
Toporin e368782b4c Satochip: Patch error 0x9C01 in cardBip32GetExtendedKey()
This error occurs when the cache memory in the card is full.
We need to flush the cache by sending the same command with the p2 flag set to 0x80.
2023-11-30 21:25:01 +01:00
Craig Raw 6ee3755ce4 cormorant: fix descriptor range calculation and extend range from pruned date only where necessary 2023-11-30 12:54:50 +02:00
Craig Raw d425933189 only allow sweeping from uncompressed keys to legacy script type addresses 2023-11-30 11:42:59 +02:00
Craig Raw 675b7ed4f8 cormorant: default to existing descriptor timestamp when extending range 2023-11-30 08:50:20 +02:00
Craig Raw 6072f6d31a add master fingerprint to passphrase entry dialog in terminal 2023-11-29 10:25:40 +02:00
Craig Raw e1fb674170 avoid showing balance in fiat when exchange source is none 2023-11-29 09:32:01 +02:00
Craig Raw d8839763a4 remove extraneous space character 2023-11-23 07:49:20 +02:00
Craig Raw 64e98cdb35 bump to v1.8.2 2023-11-22 11:59:02 +02:00
Craig Raw bde8fef35e disable rename wallet on transaction tab 2023-11-22 09:17:17 +02:00
Craig Raw 87e7a87e5e exchange source: use closing price where available 2023-11-22 08:12:32 +02:00
Craig Raw 85eb4df7e9 transaction tab: rearrange via transaction tree 2023-11-22 07:59:26 +02:00
Craig Raw bb32a1e7b1 add comment to transactions csv on the approximate nature of the fiat values 2023-11-21 17:59:13 +02:00
Craig Raw f590794589 add qr display (with save to pdf) to output descriptor wallet export options 2023-11-21 10:06:32 +02:00
Craig Raw 1e3ce7eb88 add historical fiat values to transactions csv export 2023-11-21 09:33:47 +02:00
Craig Raw ef3e2ed695 cormorant: handle checking imports and stopping before started 2023-11-20 10:56:06 +02:00
Craig Raw 74c3370277 use fallback fee rate estimates if the connected server returns an error estimating fee rates 2023-11-20 10:29:14 +02:00
Craig Raw 170e7c0abf reduce http error logging 2023-11-17 09:43:22 +02:00
Craig Raw e0486ff4a9 followup 2023-11-16 18:12:19 +02:00
Craig Raw 1d17384152 increase and decrease maximum on fee rate slider where fee rate equals or exceeds current range, set testnet fallback fee rate to 1 sat/vb 2023-11-16 16:58:24 +02:00
Craig Raw aec26d512b add oxt.me as fee rates source 2023-11-16 10:12:41 +02:00
Craig Raw 9e5a6e83d1 set smaller increment amount on fee range slider for key adjustments 2023-11-15 16:22:12 +02:00
Craig Raw eecb90e9b2 cormorant: check wallet gap limit and increase core descriptor range if necessary 2023-11-15 13:29:26 +02:00
Craig Raw b3516063b2 add null safe tests for standard account types 2023-11-15 10:52:05 +02:00
Craig Raw 1d560d6aa6 save mix to wallet index to improve handling of mix out failures 2023-11-15 10:20:58 +02:00
Craig Raw d128401a09 fix transaction diagram output size indicators in transactions with non-address outputs 2023-11-14 15:18:47 +02:00
Craig Raw 9391a397da set dialog minimum height to preferred height (or make resizable) to avoid window sizing bug in some linux environments 2023-11-14 14:29:28 +02:00
Craig Raw a5312374a8 show error dialog on cpfp if no outputs are spendable 2023-11-13 14:37:41 +02:00
Craig Raw c81c42a87c switch from httpurlconnection to jetty http client to avoid spurious dns query 2023-11-10 19:16:03 +02:00
Craig Raw d84ade5b7d add krux to wallet import options 2023-11-10 14:55:00 +02:00
Craig Raw 323d29e34a bump to v1.8.1 2023-11-09 14:37:35 +02:00
Craig Raw 95cb8c4b2c bump to v1.8.0 2023-11-09 12:55:10 +02:00
Craig Raw 2712555c72 followup 2 2023-11-09 12:50:39 +02:00
Craig Raw 72066395d6 followup 2023-11-09 12:31:18 +02:00
Craig Raw 7b3e5f37b9 improve vertical layout sizing on transaction tabs 2023-11-09 12:17:08 +02:00
Craig Raw 870a468584 add bisq segwit misderivation to mnemonic wallet discovery 2023-11-09 09:31:02 +02:00
Craig Raw 0340cba441 fix improvement to renaming wallet keystore labels for uniqueness 2023-11-09 09:10:54 +02:00
Craig Raw 91f845cbbf followup 2023-11-08 17:23:30 +02:00
Craig Raw d0f21eafd1 schedule regular check to indicate proxy status in sparrow terminal 2023-11-08 17:08:44 +02:00
Craig Raw 36c2181a7f remove keyboard shortcut+pageup/pagedown to switch tabs already added by default 2023-11-08 15:22:39 +02:00
Craig Raw 218e751333 display error message when attempting to mix from account 0 and it is not the master wallet 2023-11-08 12:29:54 +02:00
Craig Raw 995d2c5e4e temporarily disconnect from whirlpool if gap limit is increasing rapidly 2023-11-08 11:59:19 +02:00
Craig Raw 0d7ae74f0f fix whirlpool wallets with special characters in the passphrase 2023-11-08 09:27:25 +02:00
Craig Raw e6eea67c4b add search all open wallets functionality, include matches on transaction output addresses 2023-11-07 16:38:33 +02:00
Craig Raw ea3e0ca34a select all text in message sign signature field on mouse click 2023-11-07 15:16:06 +02:00
Craig Raw 3fedd8eb43 support opening multiple wallet and transaction files at once 2023-11-07 14:14:14 +02:00
Craig Raw 30408af719 add fee rate slider to private key sweep dialog 2023-11-07 14:02:17 +02:00
Craig Raw 910a400b18 fix computation of master fingerprint for satochip 2023-11-07 11:58:18 +02:00
Craig Raw f4ac18c3e1 increase gap limit where necessary to sign a psbt where global xpubs match 2023-11-02 12:38:51 +01:00
Craig Raw cb06e1aaf7 freeze and unfreeze utxos in sparrow terminal by pressing f on utxos table 2023-10-31 11:20:10 +01:00
Craig Raw d881e47ec9 indicate if proxy is enabled in sparrow terminal 2023-10-31 10:37:19 +01:00
Craig Raw dbafefb940 reload cormorant wallet if unloaded when polling bitcoind 2023-10-31 10:21:33 +01:00
Craig Raw ee6589991d add initial satochip card support 2023-10-31 09:56:31 +01:00
Craig Raw 24578dcf88 enlarge qr display dialog and increase default qr code density 2023-10-24 17:12:41 +02:00
Craig Raw ddae1a12d8 add keyboard shortcut ctrl+pageup/pagedown to switch tabs on windows/linux 2023-10-24 15:34:20 +02:00
Craig Raw 1c9b6c3eef add keyboard shortcut ctrl/cmd+alt+arrow to switch tabs 2023-10-24 15:06:21 +02:00
Craig Raw 158ecd4ab1 fix thread race issue when connecting to cormorant electrum server 2023-10-24 10:51:09 +02:00
Craig Raw b6bcdef712 minor fixes 2023-10-22 14:43:09 +02:00
Craig Raw 6eefd3f182 add duplicate payment address warning to transaction diagram 2023-10-12 12:37:44 +02:00
Craig Raw 9280504f70 add duplicate payment address warning to transaction diagram 2023-10-12 12:37:41 +02:00
Craig Raw a6a671f687 remove block hash from transaction tab fields, add to context menu for block height and timestamp 2023-10-11 13:08:42 +02:00
Craig Raw 4e3e8b7cc4 add figure caption to overview diagram on transaction tab to describe transaction 2023-10-11 11:44:40 +02:00
Craig Raw 2b8fc3900a followup 2023-10-06 13:50:44 +02:00
Craig Raw cff731dec7 ignore script type change warning when replacing wallet in settings 2023-10-05 12:37:27 +02:00
Craig Raw c9c0c35964 add krux as airgapped hww 2023-10-05 10:35:55 +02:00
Craig Raw afed5c65f5 add additional testnet public server qtornado.com 2023-10-05 08:53:12 +02:00
Craig Raw 0724c38582 add whirlpool postmix to the list of possible accounts that can be added to any legacy or segwit wallet 2023-09-29 14:37:58 +02:00
Craig Raw 31539a27ac improve fullscreen behaviour by setting dialog ownership to parent window 2023-09-29 12:02:14 +02:00
Craig Raw 201900aa0e add airgapped message signing via qr 2023-09-25 13:39:51 +02:00
Craig Raw 3dcbe34485 add scan button to qr display dialog to progress immediately to scanning 2023-09-21 14:07:58 +02:00
Craig Raw 31842cc0f2 update caravan and specter logos 2023-09-21 11:34:04 +02:00
Craig Raw f85349bd36 support hex border wallets grid pdf 2023-09-19 14:05:26 +02:00
Craig Raw a18c24e19f add /usr/lib64 as possible location for linux pcsc lib 2023-09-18 10:44:33 +02:00
Craig Raw e23c1b3872 force selection of a new configured server if currently configured server is deleted 2023-09-04 09:47:41 +02:00
Craig Raw 3c2ef43526 bump to v1.7.10 2023-08-31 13:36:36 +02:00
Craig Raw c81aae0c6a minor account renaming bug fixes 2023-08-31 10:54:16 +02:00
Craig Raw ff1a9e8a52 add or remove tooltip on account tab on renaming 2023-08-31 10:06:46 +02:00
Craig Raw 90bfa47046 add jade multisig wallet import 2023-08-31 09:34:15 +02:00
Craig Raw 6383b8b46f improve renaming wallet keystore labels for uniqueness 2023-08-30 15:21:43 +02:00
Craig Raw 76dc294748 support import and export of keystore labels in crypto-output qr codes 2023-08-30 14:22:18 +02:00
Craig Raw 0bc1dd96ed use port instead of unix socket for internal tor control to avoid bug where unix socket path is too long 2023-08-30 10:39:52 +02:00
Craig Raw 73d2d3cbbc replace hwi 2.3.1 binary with macos 10.13 compiled version 2023-08-30 09:55:08 +02:00
Craig Raw d5fdd6881c followup 2023-08-30 09:32:02 +02:00
Craig Raw b1bc25ba04 propagate transaction label changes to inputs, outputs and addresses where their existing labels were set in the same manner 2023-08-29 16:03:46 +02:00
Craig Raw 2c1204c247 add treetable config classes 2023-08-23 13:52:33 +02:00
Craig Raw 05a1fd8e8d refactor cointreetable column sorting, add default sorting if table was empty 2023-08-23 12:18:02 +02:00
Craig Raw 97f21394a7 add tooltip to account tab where label is truncated 2023-08-23 11:24:24 +02:00
Craig Raw c57a445046 add mempoolfullrbf config variable to enable rbf functionality on mempool transactions without checking sequence flags (default false) 2023-08-17 16:18:27 +02:00
Craig Raw fb981f1548 suggest clearing any existing keystores when script type is changed in settings 2023-08-17 15:35:33 +02:00
Craig Raw 407dde2703 upgrade to hwi 2.3.1 2023-08-17 12:33:03 +02:00
Craig Raw f175139fd3 add wallet summary dialog 2023-08-14 16:11:55 +02:00
Craig Raw bebd7eebe5 handle core bug where listwalletdir returns empty results 2023-08-14 10:02:35 +02:00
Craig Raw e01f6b42b1 add fulcrum.sethforprivacy.com public mainnet electrum server 2023-08-07 13:57:27 +02:00
Craig Raw bd1c6c076e avoid adding inputs when constructing a consolidation transaction replacement, allowing output to decrease 2023-08-07 12:54:29 +02:00
Craig Raw 46b1bd2fd2 improve address resolution error message 2023-08-07 12:20:07 +02:00
Craig Raw 2d7c5dcec7 terminal: check if theme is present to avoid exception on utxo history update 2023-08-07 12:00:58 +02:00
Craig Raw 93bcf6cef9 fix single character multisig threshold parsing issue 2023-07-23 13:13:30 +02:00
Craig Raw 4b6a03ef56 for zbar scans, return scanned characters as raw bytes 2023-07-23 12:48:04 +02:00
Craig Raw 7ff4230e13 bump to v1.7.9 2023-07-18 16:22:43 +02:00
Craig Raw 7d7967ec00 improve handling of invalid bip322 signatures 2023-07-18 12:25:39 +02:00
Craig Raw b0883f034b change type and name of enable zbar config variable 2023-07-18 11:49:25 +02:00
Craig Raw f88628c469 terminal: set scrollbar to top if scrolled below new utxo table row count 2023-07-18 10:58:11 +02:00
Craig Raw ac7a964edf make alert dialogs resizable 2023-07-18 10:37:27 +02:00
Craig Raw 7bb22419df add rename wallet menu command 2023-07-18 10:09:37 +02:00
Craig Raw c443bc78d3 add taproot script type to connected wallet import options 2023-07-17 11:57:34 +02:00
Craig Raw a5519537c5 add tooltip to save final transaction button to suggest connecting to a server in order to broadcast 2023-07-17 11:49:17 +02:00
Craig Raw ef67d1f33b show warning dialog on submit bug report to redirect users to support where appropriate 2023-07-17 11:44:16 +02:00
Craig Raw 31bd64f821 show warning in preferences if currencies could not be retrieved from exchange rate source 2023-07-17 11:17:16 +02:00
Craig Raw 4c408ac7b1 make send to many dialog non-modal, menu command brings an existing dialog to foreground 2023-07-17 10:52:01 +02:00
Craig Raw 30a9c1208a add config variable to disable zbar scanning 2023-07-17 08:48:34 +02:00
Craig Raw 96fd824a3e add zbar qr reader for all qr scans 2023-07-14 15:04:27 +02:00
Craig Raw 0a469a380b when constructing rbf and cpfp transactions, add any additional utxos by output group if effective fee is sufficient 2023-07-11 11:25:29 +02:00
Craig Raw 5e3f31de30 followup 2023-07-11 10:07:00 +02:00
Craig Raw af9fb8694e set initial fee for rbf tx to satisfy minimum relay requirements 2023-07-11 09:50:19 +02:00
Craig Raw ebfdfc0c9f use txo filters for all wallet transaction output filtering, fixing overselection of inputs during rbf 2023-07-11 09:08:40 +02:00
Craig Raw c9d6bb350d rearrange recent files list when tabs are moved left or right 2023-07-05 09:59:22 +02:00
Craig Raw f980516462 various minor improvements 2023-07-05 09:48:48 +02:00
Craig Raw 795892f7c9 show error for bip322 multisig signatures 2023-07-04 09:22:15 +02:00
Craig Raw 9576581d89 add bip322 message signing for singlesig addresses including p2tr 2023-07-04 08:51:38 +02:00
Craig Raw 296223130e cormorant: optimize memory used for calculating fee rate histogram 2023-06-30 09:00:37 +02:00
Craig Raw 87e2da0e01 followup 2023-06-26 12:25:55 +02:00
Craig Raw 9156ea1114 preserve payment labels when using rbf on multiple payment transactions 2023-06-26 12:15:58 +02:00
Craig Raw b8fc2fd59e only keep older mempool histogram entries at ten minute intervals 2023-06-26 10:45:16 +02:00
Craig Raw ef9723ed44 add additional utxos to cpfp transaction if output value is below dust threshold 2023-06-23 13:39:38 +02:00
Craig Raw 5105b503ea set transaction label to comma separated list of payment labels when multiple payments are made 2023-06-23 10:30:51 +02:00
Craig Raw 9bcb34e7d1 retain utxo frozen status on wallet refresh 2023-06-23 09:55:24 +02:00
Craig Raw 185a17edce bump to v1.7.8 2023-06-22 12:56:29 +02:00
Craig Raw f8fa7f4cf2 add separate button to backup tapsigner without changing pin 2023-06-22 10:41:59 +02:00
Craig Raw bcbb531414 fix ui button update when stopping mixing on terminal 2023-06-22 09:10:52 +02:00
Craig Raw 2deab05c45 remove path from non-batched electrum server requests 2023-06-22 08:21:57 +02:00
Craig Raw c7923300c6 update kmp-tor to v1.4.3, fix storage tests 2023-06-22 07:54:48 +02:00
Craig Raw c4651025be format fee rates in broadcast errors using unit format 2023-06-21 16:42:57 +02:00
Craig Raw 2897f88c8b fix unit format switching on send tab 2023-06-21 15:47:21 +02:00
Craig Raw ee20a6980b fix incorrect hours check for 24h mempool fee rates toggle 2023-06-21 11:40:04 +02:00
Craig Raw 721c446fa8 update seedsigner help text 2023-06-21 10:36:34 +02:00
Craig Raw 7c43ee7208 add context menu options to date column in utxos table to freeze utxo and view transaction 2023-06-21 09:33:42 +02:00
Craig Raw c6ea37e081 add 24h mempool fees chart toggle 2023-06-21 08:33:05 +02:00
Craig Raw 171bf24133 optimize fetching mempool entries for fee histogram when connected to bitcoin core, fix and improve mempool fee rates chart 2023-06-20 16:15:19 +02:00
Craig Raw 3242f00812 improve performance on large wallets with high address reuse 2023-06-15 12:19:09 +02:00
Craig Raw 15500b6535 highlight csv download buttons 2023-06-13 14:13:44 +02:00
Craig Raw 700c880b92 improve error message on broadcasting a tx with fee rate below the purge rate for the connected mempool 2023-06-11 10:50:19 +02:00
Craig Raw 7f2c07c918 set all witness utxos on psbt inputs before attempting to sign sweep tx 2023-06-08 11:07:32 +02:00
Craig Raw 0745d21761 fix export to specter desktop without any wallet history 2023-06-08 08:58:43 +02:00
Craig Raw 50aa9b4dcb ensure canonical ordering of key expressions in multisig descriptor qr 2023-06-07 10:21:29 +02:00
Craig Raw fe50cb845e log batch errors getting fee estimates from server 2023-06-05 11:49:10 +02:00
Craig Raw f4b9807285 widen mnemonic grid dialog to accommodate word numbers 2023-06-04 10:02:55 +02:00
Craig Raw f534beb624 add additional rbf tx inputs if needed as required fee is increased 2023-06-01 15:31:15 +02:00
Craig Raw 719cfaa906 support airgapped keystore import of a tapsigner with a custom derivation path 2023-05-31 15:50:06 +02:00
Craig Raw 98d9a6882b look for supported cards across all connected card terminals 2023-05-31 14:25:45 +02:00
Craig Raw dbbeaf67b6 support spendable property on utxos in bip329 wallet label imports and exports 2023-05-31 10:42:32 +02:00
Craig Raw 2a542bb8b9 avoid multiple selection on border wallets grid, display word number, clarify recovery phrase purpose on pdf 2023-05-30 14:01:45 +02:00
Craig Raw 29b630f6bf update to hummingbird v1.6.7 to support pair path components and unique part progress indicator 2023-05-29 17:20:43 +02:00
Craig Raw 3aa00076c6 trim leading and trailing whitespace from pay to address field 2023-05-25 16:15:38 +02:00
Craig Raw 3cf99961d3 improve error message when broadcasting an rbf transaction with insufficient fee, indicate minimum required fee if available 2023-05-25 14:48:32 +02:00
Craig Raw 742727d6f2 add ctrl/cmd+b keyboard shortcut for quickly switching from btc to satoshis and back 2023-05-25 13:22:44 +02:00
Craig Raw 5d99eee89a invert cropped frame and scan for inverted qrs 2023-05-25 12:29:43 +02:00
Craig Raw b52be27a99 support scanning text qr containing seed words 2023-05-15 15:49:39 -05:00
Craig Raw e68f177e4a bump to v1.7.7 2023-04-14 18:31:10 -06:00
Craig Raw e0a6626650 add further guidance when regenerating a border wallets grid 2023-04-14 16:41:47 -06:00
Craig Raw 1d8888bb14 fix button placement on border wallets dialog in windows 2023-04-14 15:59:24 -06:00
Craig Raw d0958b7936 clear border wallets selected cells on grid initialization 2023-04-14 13:26:29 -06:00
Craig Raw d9d316a627 bump to v1.7.6 2023-04-14 10:25:08 -06:00
Craig Raw 0270910b74 update specter desktop wallet import 2023-04-14 08:29:24 -06:00
Craig Raw 432e0642ca cormorant: improve error message when core wallet support is disabled 2023-04-14 07:50:06 -06:00
Craig Raw c7ab8e4601 support broadcasting via mempool.space when using signet 2023-04-14 07:41:00 -06:00
Craig Raw 1a46f8a643 preserve order of cell selection in border wallets grid 2023-04-14 06:36:34 -06:00
Craig Raw 04145bde74 add border wallets number grid support 2023-04-06 14:57:56 +02:00
Craig Raw 483e4c8f38 always show tapsigner in airgapped import options 2023-04-05 08:24:18 +02:00
Craig Raw fe4468d49d bump to v1.7.5 2023-04-05 08:10:36 +02:00
260 changed files with 8163 additions and 1379 deletions
+3
View File
@@ -2,6 +2,9 @@ name: Package
on: workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ${{ matrix.os }}
+16 -29
View File
@@ -7,7 +7,7 @@ plugins {
id 'org.beryx.jlink' version '2.26.0'
}
def sparrowVersion = '1.7.4'
def sparrowVersion = '1.8.2'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -24,7 +24,7 @@ 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.2'
def vKmpTor = '1.4.3'
def kmpOs = osName
if(os.macOsX) {
kmpOs = "macos"
@@ -89,7 +89,7 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('com.sparrowwallet:hummingbird:1.7.3')
implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
@@ -105,7 +105,7 @@ dependencies {
}
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.6.4')
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'
@@ -121,7 +121,7 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.36')
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')
@@ -182,7 +182,7 @@ run {
"--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=kotlin.stdlib=kotlinx.coroutines.core.jvm"]
"--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",
@@ -241,7 +241,7 @@ jlink {
"--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=kotlin.stdlib=kotlinx.coroutines.core.jvm"]
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
@@ -508,7 +508,7 @@ extraJavaModuleInfo {
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
}
module('nightjar-0.2.36.jar', 'com.sparrowwallet.nightjar', '0.2.36') {
module('nightjar-0.2.39.jar', 'com.sparrowwallet.nightjar', '0.2.39') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -605,19 +605,6 @@ extraJavaModuleInfo {
requires('javafx.graphics')
requires('java.xml')
}
module('kotlinx-coroutines-core-jvm-1.6.4.jar', 'kotlinx.coroutines.core.jvm', '1.6.4') {
exports('kotlinx.coroutines')
requires('kotlin.stdlib')
requires('java.instrument')
uses('kotlinx.coroutines.CoroutineExceptionHandler')
uses('kotlinx.coroutines.internal.MainDispatcherFactory')
}
module('kotlinx-coroutines-javafx-1.6.4.jar', 'kotlinx.coroutines.javafx', '1.6.4') {
exports('kotlinx.coroutines.javafx')
requires('kotlinx.coroutines.core.jvm')
requires('kotlin.stdlib')
requires('javafx.graphics')
}
module("kmp-tor-jvm-${vKmpTor}.jar", 'kmp.tor.jvm', "${vTor}-${vKmpTor}") {
exports('io.matthewnelson.kmp.tor')
requires('kmp.tor.binary.extract.jvm')
@@ -625,7 +612,7 @@ extraJavaModuleInfo {
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
requires('java.management')
}
if(kmpOs == "linux" && kmpArch == "arm64") {
@@ -652,7 +639,7 @@ extraJavaModuleInfo {
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
requires('kotlinx.atomicfu')
requires('kmp.tor.controller.jvm')
requires('kmp.tor.common.jvm')
@@ -685,7 +672,7 @@ extraJavaModuleInfo {
exports('io.matthewnelson.kmp.tor.controller.internal.controller')
requires('kmp.tor.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlinx.coroutines.core.jvm')
requires('kotlinx.coroutines.core')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
requires('encoding.core.jvm')
@@ -703,7 +690,7 @@ extraJavaModuleInfo {
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core.jvm')
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')
@@ -717,14 +704,14 @@ extraJavaModuleInfo {
exports('io.matthewnelson.kmp.tor.binary.geoip')
exports('kmptor')
}
module("encoding-base16-jvm-1.2.1.jar", 'encoding.base16.jvm', "1.2.1") {
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("encoding-base32-jvm-1.2.1.jar", 'encoding.base32.jvm', "1.2.1")
module("encoding-base64-jvm-1.2.1.jar", 'encoding.base64.jvm', "1.2.1")
module("encoding-core-jvm-1.2.1.jar", 'encoding.core.jvm', "1.2.1") {
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')
}
+1 -1
View File
@@ -82,7 +82,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="1.7.3"
GIT_TAG="1.8.1"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: fe19c86544...42f279e5e7
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.7.4</string>
<string>1.8.2</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,6 +226,8 @@ 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()));
};
@@ -320,6 +330,8 @@ public class AppController implements Initializable {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList()));
});
registerShortcuts();
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null) {
unit = BitcoinUnit.AUTO;
@@ -328,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) {
@@ -370,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());
@@ -397,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) {
@@ -440,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);
@@ -464,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) {
@@ -517,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();
@@ -535,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);
}
}
}
@@ -586,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();
@@ -604,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();
@@ -631,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();
@@ -694,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);
@@ -792,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();
}
}
@@ -836,6 +888,10 @@ public class AppController implements Initializable {
}
}
public void renameWallet(ActionEvent event) {
renameWallet(getSelectedWalletForm());
}
public void deleteWallet(ActionEvent event) {
deleteWallet(getSelectedWalletForm());
}
@@ -953,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();
@@ -974,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);
}
}
}
@@ -1002,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;
@@ -1070,6 +1130,7 @@ public class AppController implements Initializable {
public void importWallet(ActionEvent event) {
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();
@@ -1107,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();
@@ -1129,6 +1191,7 @@ public class AppController implements Initializable {
private void addImportedWallet(Wallet wallet) {
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();
@@ -1174,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) {
@@ -1239,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
@@ -1257,6 +1322,7 @@ 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());
@@ -1267,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);
}
@@ -1279,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();
@@ -1291,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)));
}
});
@@ -1310,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));
}
@@ -1323,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();
@@ -1336,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);
}
@@ -1363,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);
}
@@ -1370,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);
}
@@ -1382,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();
}
}
@@ -1427,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();
}
}
}
@@ -1524,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();
@@ -1600,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());
@@ -1853,6 +1982,7 @@ public class AppController implements Initializable {
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 -> {
@@ -1862,6 +1992,7 @@ public class AppController implements Initializable {
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);
@@ -1944,12 +2075,38 @@ public class AppController implements Initializable {
}
}
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);
@@ -1959,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();
@@ -1981,15 +2138,15 @@ 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.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage);
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage, deleteBackups);
deleteWalletService.setDelay(Duration.seconds(3));
deleteWalletService.setPeriod(Duration.hours(1));
deleteWalletService.setOnSucceeded(event -> {
@@ -2005,7 +2162,7 @@ public class AppController implements Initializable {
deleteWalletService.start();
});
} else {
Platform.runLater(() -> deleteStorage(storage));
Platform.runLater(() -> deleteStorage(storage, deleteBackups));
}
}
@@ -2017,10 +2174,16 @@ public class AppController implements Initializable {
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 renamedWallet = AppServices.get().getWallet(walletId);
renamedWallet.setLabel(label);
@@ -2045,9 +2208,18 @@ public class AppController implements Initializable {
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();
@@ -2797,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
@@ -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;
@@ -63,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;
@@ -83,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;
@@ -94,7 +96,7 @@ public class AppServices {
private InteractionServices interactionServices;
private static PayNymService payNymService;
private static HttpClientService httpClientService;
private final Application application;
@@ -245,8 +247,8 @@ 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();
}
@@ -511,18 +513,18 @@ 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() {
@@ -563,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() {
@@ -651,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;
}
@@ -669,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);
@@ -678,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() {
@@ -897,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) {
@@ -937,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);
@@ -999,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");
@@ -1022,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();
@@ -1046,6 +1071,10 @@ public class AppServices {
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
}
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
@@ -15,13 +15,13 @@ import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
import static com.sparrowwallet.sparrow.AppServices.*;
public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
alert.initOwner(getActiveWindow());
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
@@ -53,6 +53,8 @@ public class DefaultInteractionServices implements InteractionServices {
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
}
alert.setResizable(true);
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@@ -60,6 +62,7 @@ public class DefaultInteractionServices implements InteractionServices {
@Override
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
passphraseDialog.initOwner(getActiveWindow());
return passphraseDialog.showAndWait();
}
}
@@ -45,6 +45,7 @@ public class SparrowDesktop extends Application {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
AppServices.initialize(this);
@@ -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.4";
public static final String APP_VERSION = "1.8.2";
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";
@@ -12,6 +12,7 @@ public enum UnitFormat {
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
@@ -30,6 +31,10 @@ public enum UnitFormat {
return currencyFormat;
}
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator('.');
@@ -42,6 +47,7 @@ public enum UnitFormat {
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
@@ -60,6 +66,10 @@ public enum UnitFormat {
return currencyFormat;
}
public DecimalFormat getTableCurrencyFormat() {
return tableCurrencyFormat;
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator(',');
@@ -78,6 +88,8 @@ public enum UnitFormat {
public abstract DecimalFormat getCurrencyFormat();
public abstract DecimalFormat getTableCurrencyFormat();
public String formatBtcValue(Long amount) {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
@@ -94,6 +106,10 @@ public enum UnitFormat {
return getCurrencyFormat().format(amount);
}
public String tableFormatCurrencyValue(double amount) {
return getTableCurrencyFormat().format(amount);
}
public String getGroupingSeparator() {
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
}
@@ -22,6 +22,7 @@ public class WelcomeDialog extends Dialog<Mode> {
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
@@ -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()), false));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false, getTreeTableView()));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
@@ -4,8 +4,7 @@ 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.Keystore;
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.KeystoreImportEvent;
@@ -26,18 +25,25 @@ 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 final List<ChildNumber> derivation;
private List<ChildNumber> derivation;
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
@@ -62,11 +68,8 @@ public class CardImportPane extends TitledDescriptionPane {
}
private void importCard() {
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getPinEntry());
showHideLink.setVisible(false);
setExpanded(true);
if(!isReaderAvailable()) {
setError("No reader", "No card reader was detected.");
importButton.setDisable(false);
return;
}
@@ -77,6 +80,15 @@ public class CardImportPane extends TitledDescriptionPane {
});
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));
@@ -98,7 +110,7 @@ public class CardImportPane extends TitledDescriptionPane {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
setContent(getPinAndDerivationEntry());
} else {
log.error("Error importing keystore from card", event.getSource().getException());
setError("Import Error", rootCause.getMessage());
@@ -109,6 +121,75 @@ public class CardImportPane extends TitledDescriptionPane {
}
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");
@@ -161,6 +242,13 @@ public class CardImportPane extends TitledDescriptionPane {
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();
@@ -175,7 +263,7 @@ public class CardImportPane extends TitledDescriptionPane {
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPadding(new Insets(10, 30, 0, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
@@ -183,6 +271,51 @@ public class CardImportPane extends TitledDescriptionPane {
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;
@@ -1,5 +1,6 @@
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;
@@ -22,20 +23,25 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
private final CheckBox backupFirst;
private final ButtonType okButtonType;
public CardPinDialog() {
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("Change Card PIN");
dialogPane.setHeaderText("Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
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(260);
dialogPane.setPrefHeight(backupOnly ? 135 : 260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
@@ -63,24 +69,33 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
backupField.setText("Backup First:");
backupField.getInputs().add(backupFirst);
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
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() < 6 || existingPin.getText().length() > 32));
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < 6 || newPin.getText().length() > 32));
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("Change", ButtonBar.ButtonData.OK_DONE);
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() < 6 || existingPin.getText().length() > 32
|| newPin.getText().length() < 6 || newPin.getText().length() > 32
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);
@@ -70,7 +70,7 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
setContextMenu(contextMenu);
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.showConfirmations(transactionEntry.confirmationsProperty());
tooltip.showConfirmations(transactionEntry.confirmationsProperty(), transactionEntry.isCoinbase());
if(transactionEntry.isConfirming()) {
ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations());
@@ -119,6 +119,7 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
private static final class CoinTooltip extends Tooltip {
private final IntegerProperty confirmationsProperty = new SimpleIntegerProperty();
private boolean showConfirmations;
private boolean isCoinbase;
private String value;
public void setValue(String value) {
@@ -126,8 +127,9 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
setTooltipText();
}
public void showConfirmations(IntegerProperty txEntryConfirmationsProperty) {
public void showConfirmations(IntegerProperty txEntryConfirmationsProperty, boolean coinbase) {
showConfirmations = true;
isCoinbase = coinbase;
int confirmations = txEntryConfirmationsProperty.get();
if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
@@ -155,7 +157,7 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
if(confirmations == 0) {
return "Unconfirmed in mempool";
} else if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
return confirmations + " confirmation" + (confirmations == 1 ? "" : "s");
return confirmations + " confirmation" + (confirmations == 1 ? "" : "s") + (isCoinbase ? ", immature coinbase" : "");
} else {
return BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM + "+ confirmations";
}
@@ -12,7 +12,7 @@ import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
}
public UnitFormat getUnitFormat() {
@@ -29,7 +29,7 @@ public class CoinTextFormatter extends TextFormatter<String> {
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -17,6 +18,7 @@ import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import javafx.scene.layout.StackPane;
@@ -28,6 +30,7 @@ import java.util.Optional;
public class CoinTreeTable extends TreeTableView<Entry> {
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
private CurrencyRate currencyRate;
public BitcoinUnit getBitcoinUnit() {
return bitcoinUnit;
@@ -64,6 +67,18 @@ public class CoinTreeTable extends TreeTableView<Entry> {
}
}
public CurrencyRate getCurrencyRate() {
return currencyRate;
}
public void setCurrencyRate(CurrencyRate currencyRate) {
this.currencyRate = currencyRate;
if(!getChildren().isEmpty()) {
refresh();
}
}
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
if(getRoot() != null) {
Entry entry = getRoot().getValue();
@@ -94,6 +109,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> {
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);
}
}
}
@@ -10,12 +10,16 @@ import javafx.scene.control.MenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.event.EventHandler;
import javafx.scene.input.MouseEvent;
public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final Tooltip tooltip;
private final CoinContextMenu contextMenu;
private BitcoinUnit bitcoinUnit;
public CopyableCoinLabel() {
this("Unknown");
}
@@ -23,6 +27,21 @@ public class CopyableCoinLabel extends CopyableLabel {
public CopyableCoinLabel(String text) {
super(text);
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
setOnMouseClicked(event -> {
if(bitcoinUnit == null) {
bitcoinUnit = Config.get().getBitcoinUnit();
}
if(bitcoinUnit == BitcoinUnit.SATOSHIS) {
bitcoinUnit = BitcoinUnit.BTC;
} else {
bitcoinUnit = BitcoinUnit.SATOSHIS;
}
refresh(Config.get().getUnitFormat(), bitcoinUnit);
});
tooltip = new Tooltip();
contextMenu = new CoinContextMenu();
}
@@ -63,6 +82,8 @@ public class CopyableCoinLabel extends CopyableLabel {
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
}
this.bitcoinUnit = unit;
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue);
setText(btcValue);
@@ -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);
});
@@ -91,6 +91,7 @@ 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());
@@ -300,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();
@@ -673,8 +673,8 @@ public class DevicePane extends TitledDescriptionPane {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
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);
@@ -795,7 +795,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private void handleCardOperation(Service<?> service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler<WorkerStateEvent> successHandler) {
if(pinRequired && pin.get().length() < 6) {
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);
@@ -940,7 +940,7 @@ public class DevicePane extends TitledDescriptionPane {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < 6) {
if(pin.get().length() < device.getModel().getMinPinLength()) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(getAddressButton));
showHideLink.setVisible(false);
@@ -1047,6 +1047,75 @@ public class DevicePane extends TitledDescriptionPane {
}
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");
@@ -36,7 +36,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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;
@@ -100,7 +100,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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());
@@ -124,7 +124,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(nodeEntry.getNode().toString());
@@ -149,6 +149,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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);
@@ -216,7 +217,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
.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());
@@ -230,31 +231,47 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
.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);
@@ -263,17 +280,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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) {
@@ -315,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
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() {
@@ -331,30 +352,57 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
.map(HashIndexEntry::getHashIndex)
.collect(Collectors.toList());
if(ourOutputs.isEmpty()) {
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend");
AppServices.showErrorDialog("No spendable outputs", "None of the outputs on this transaction are spendable.\n\nEnsure that the outputs are not frozen" +
(transactionEntry.getConfirmations() <= 0 ? ", and spending unconfirmed UTXOs is allowed." : "."));
return;
}
BlockTransactionHashIndex utxo = ourOutputs.get(0);
BlockTransactionHashIndex cpfpUtxo = ourOutputs.get(0);
Address freshAddress = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress();
TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), freshAddress.getOutputScript());
long dustThreshold = freshAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
double inputSize = freshAddress.getScriptType().getInputVbytes();
double vSize = inputSize + txOutput.getLength();
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(List.of(cpfpUtxo)), new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
List<BlockTransactionHashIndex> utxos = new ArrayList<>();
utxos.add(cpfpUtxo);
long inputTotal = cpfpUtxo.getValue();
while((inputTotal - (long)(getMaxFeeRate() * vSize)) < dustThreshold && !outputGroups.isEmpty()) {
//If there is insufficient input value, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
utxos.add(utxo);
inputTotal += utxo.getValue();
vSize += inputSize;
}
}
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true);
Payment payment = new Payment(freshAddress, label, inputTotal, true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false)));
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
}
private static boolean canRBF(BlockTransaction blockTransaction) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
}
private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet();
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB || wallet.getKeystores().get(0).getWalletModel().isCard()) &&
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
@@ -427,7 +475,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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;
@@ -504,7 +552,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
});
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 -> {
@@ -515,7 +563,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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 -> {
@@ -600,7 +648,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems) {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems, TreeTableView<Entry> treetable) {
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
@@ -618,6 +666,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.initOwner(treetable.getScene().getWindow());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
@@ -691,7 +740,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
}
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());
@@ -747,6 +796,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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");
@@ -781,6 +832,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
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();
@@ -116,6 +116,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
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();
@@ -126,6 +127,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
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);
@@ -155,6 +157,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
} else {
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
@@ -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,6 +29,8 @@ 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;
@@ -111,19 +118,16 @@ public class FileWalletExportPane extends TitledDescriptionPane {
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(buttonBox.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
String walletPassword = password.get().asString();
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
try {
exportWallet(file, decryptedWallet);
} finally {
decryptedWallet.clearPrivate();
}
exportWallet(file, decryptedWallet, walletPassword);
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
@@ -133,28 +137,42 @@ public class FileWalletExportPane extends TitledDescriptionPane {
decryptWalletService.start();
}
} else {
exportWallet(file, wallet);
exportWallet(file, wallet, null);
}
}
private void exportWallet(File file, Wallet exportWallet) {
private void exportWallet(File file, Wallet exportWallet, String password) {
try {
if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(exportWallet, outputStream);
FileWalletExportService fileWalletExportService = new FileWalletExportService(exporter, file, exportWallet, password);
fileWalletExportService.setOnSucceeded(event -> {
EventManager.get().post(new WalletExportEvent(exportWallet));
}
});
fileWalletExportService.setOnFailed(event -> {
Throwable e = event.getSource().getException();
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
});
fileWalletExportService.start();
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(exportWallet, outputStream);
exporter.exportWallet(exportWallet, outputStream, password);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || 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) {
@@ -163,6 +181,42 @@ public class FileWalletExportPane extends TitledDescriptionPane {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
} finally {
if(file == null && password != null) {
exportWallet.clearPrivate();
}
}
}
public static class FileWalletExportService extends Service<Void> {
private final WalletExport exporter;
private final File file;
private final Wallet wallet;
private final String password;
public FileWalletExportService(WalletExport exporter, File file, Wallet wallet, String password) {
this.exporter = exporter;
this.file = file;
this.wallet = wallet;
this.password = password;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(wallet, outputStream, password);
} finally {
if(password != null) {
wallet.clearPrivate();
}
}
return null;
}
};
}
}
}
@@ -12,7 +12,7 @@ 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;
}
@@ -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,16 +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.*;
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.*;
@@ -22,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;
@@ -37,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);
@@ -46,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;
/**
@@ -89,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;
@@ -125,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) {
@@ -136,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();
@@ -154,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);
@@ -176,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);
@@ -194,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();
});
@@ -209,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();
@@ -220,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
}
@@ -249,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()) {
@@ -258,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);
}
});
}
@@ -266,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 && !wallet.getKeystores().get(0).getWalletModel().isCard()) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a connected 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 {
@@ -279,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) || address.getScriptType() == ScriptType.P2SH);
return address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH;
} catch (InvalidAddressException e) {
return false;
}
@@ -302,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()) {
@@ -325,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();
@@ -340,6 +385,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
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();
@@ -353,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
}
@@ -395,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);
@@ -404,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());
@@ -415,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();
@@ -1,5 +1,6 @@
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;
@@ -14,17 +15,16 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
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 org.controlsfx.tools.Platform;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -40,13 +40,15 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
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. Note the order of selection is important!");
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));
@@ -54,28 +56,56 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
Grid grid = getGrid(emptyWordGrid);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> {
try {
Field f = event.getClass().getDeclaredField(Platform.getCurrent() == Platform.OSX ? "metaDown" : "controlDown");
f.setAccessible(true);
f.set(event, true);
} catch(IllegalAccessException | NoSuchFieldException e) {
//ignore
}
});
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) {
int numWords = c.getList().size();
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);
@@ -93,7 +123,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
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);
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);
@@ -107,8 +137,9 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null;
});
dialogPane.setPrefWidth(850);
dialogPane.setPrefWidth(952);
dialogPane.setPrefHeight(500);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
@@ -137,15 +168,33 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
}
private List<String> getSelectedWords() {
List<String> abbreviations = spreadsheetView.getSelectionModel().getSelectedCells().stream()
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) {
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
words.add(word);
break;
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);
}
}
}
}
@@ -217,6 +266,8 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
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());
@@ -225,18 +276,21 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
});
button = loadButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP) {
} 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(12);
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()) {
@@ -251,6 +305,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(clearButton, buttonData);
clearButton.setOnAction(event -> {
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
});
@@ -18,8 +18,8 @@ public class MnemonicKeystoreEntryPane extends MnemonicKeystorePane {
private boolean generated;
public MnemonicKeystoreEntryPane(int numWords) {
super(DeterministicSeed.Type.BIP39.getName(), "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
public MnemonicKeystoreEntryPane(String name, int numWords) {
super(name, "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
@@ -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");
@@ -97,9 +97,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
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);
@@ -133,6 +135,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
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();
@@ -109,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) {
@@ -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,6 +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;
@@ -14,6 +15,9 @@ 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;
@@ -46,10 +50,7 @@ import tornadofx.control.Form;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
@@ -62,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();
@@ -146,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);
@@ -170,6 +190,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
boolean isValidKey = isValidKey();
createButton.setDisable(!isValidKey || !isValidToAddress());
setScriptTypes(isValidKey);
if(isValidKey) {
setFromAddress();
}
@@ -201,6 +222,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
setResultConverter(dialogButton -> null);
dialogPane.setPrefWidth(680);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
@@ -224,6 +250,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private void decryptKey() {
PassphraseDialog passphraseDialog = new PassphraseDialog();
passphraseDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optPassphrase = passphraseDialog.showAndWait();
if(optPassphrase.isPresent()) {
try {
@@ -263,8 +290,19 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
keyAddress.setText(address.toString());
}
private void setScriptTypes(boolean isValidKey) {
boolean compressed = !isValidKey || getPrivateKey().getKey().isCompressed();
if(compressed && !keyScriptType.getItems().equals(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE))) {
keyScriptType.getItems().addAll(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE).stream().filter(s -> !keyScriptType.getItems().contains(s)).collect(Collectors.toList()));
} else if(!compressed && !keyScriptType.getItems().equals(List.of(ScriptType.P2PKH))) {
keyScriptType.getSelectionModel().select(0);
keyScriptType.getItems().removeIf(scriptType -> scriptType != ScriptType.P2PKH);
}
}
private void scanPrivateKey() {
QRScanDialog qrScanDialog = new QRScanDialog();
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
if(result.isPresent() && result.get().payload != null) {
key.setText(result.get().payload);
@@ -294,6 +332,7 @@ 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();
@@ -312,6 +351,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
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();
@@ -330,6 +370,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
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);
}
@@ -352,7 +393,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
noFeeTransaction.addOutput(sweepOutput);
Double feeRate = AppServices.getDefaultFeeRate();
double feeRate = feeRange.getFeeRate();
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
fee++;
@@ -378,11 +419,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
PSBT psbt = new PSBT(transaction);
//Set witness UTXOs on PSBT inputs first - they are all required when hashing for a Taproot signature
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
}
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
TransactionInput txInput = transaction.getInputs().get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
@@ -414,6 +461,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
return glyph;
}
private void updateFeeRate() {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
feeRate.setText(format.getCurrencyFormat().format(feeRange.getFeeRate()) + " sats/vB");
}
@Subscribe
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
feeRange.updateTrackHighlight();
}
public class PassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase;
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.control;
public enum QRDensity {
NORMAL("Normal", 250),
NORMAL("Normal", 400),
LOW("Low", 80);
private final String name;
@@ -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;
@@ -29,18 +30,21 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@SuppressWarnings("deprecation")
public class QRDisplayDialog extends Dialog<UR> {
public class QRDisplayDialog extends Dialog<ButtonType> {
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int ANIMATION_PERIOD_MILLIS = 200;
private static final 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 UREncoder encoder;
@@ -58,14 +62,14 @@ public class QRDisplayDialog extends Dialog<UR> {
private static boolean initialDensityChange;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), addLegacyEncodingOption);
this(UR.fromBytes(type, data), 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.addLegacyEncodingOption = addLegacyEncodingOption;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
@@ -98,18 +102,29 @@ public class QRDisplayDialog extends Dialog<UR> {
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();
@@ -121,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() {
@@ -153,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());
@@ -280,6 +306,12 @@ public class QRDisplayDialog extends Dialog<UR> {
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);
@@ -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;
@@ -170,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);
@@ -207,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));
}
@@ -351,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);
}
}
@@ -426,6 +442,39 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
CryptoBip39 cryptoBip39 = (CryptoBip39)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoBip39);
return new Result(seed);
} else if(urRegistryType.equals(RegistryType.PSBT)) {
URPSBT urPSBT = (URPSBT)ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(urPSBT.getPsbt(), false);
return new Result(psbt);
} catch(Exception e) {
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
return new Result(new URException("Error parsing PSBT from UR type " + urRegistryType, e));
}
} else if(urRegistryType.equals(RegistryType.ADDRESS)) {
URAddress urAddress = (URAddress)ur.decodeFromRegistry();
Address address = getAddress(urAddress);
if(address != null) {
return new Result(BitcoinURI.fromAddress(address));
} else {
return new Result(new URException("Unknown " + urRegistryType + " type of " + urAddress.getType()));
}
} else if(urRegistryType.equals(RegistryType.HDKEY)) {
URHDKey urHDKey = (URHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(urHDKey);
return new Result(extendedKey, urHDKey.getName());
} else if(urRegistryType.equals(RegistryType.OUTPUT_DESCRIPTOR)) {
UROutputDescriptor urOutputDescriptor = (UROutputDescriptor)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(urOutputDescriptor);
return new Result(outputDescriptor);
} else if(urRegistryType.equals(RegistryType.ACCOUNT_DESCRIPTOR)) {
URAccountDescriptor urAccountDescriptor = (URAccountDescriptor)ur.decodeFromRegistry();
List<Wallet> wallets = getWallets(urAccountDescriptor);
return new Result(wallets);
} else if(urRegistryType.equals(RegistryType.SEED)) {
URSeed urSeed = (URSeed)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(urSeed);
return new Result(seed);
} else {
log.error("Unsupported UR type " + urRegistryType);
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
@@ -467,7 +516,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) {
@@ -485,18 +536,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");
@@ -543,11 +598,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
if(cryptoKeypath != null) {
if(cryptoKeypath.getComponents().stream().anyMatch(PathComponent::isWildcard)) {
throw new IllegalArgumentException("Wildcard derivation paths are not supported");
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 -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
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));
}
@@ -561,6 +617,40 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private DeterministicSeed getSeed(CryptoBip39 cryptoBip39) {
return new DeterministicSeed(cryptoBip39.getWords(), null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
}
private OutputDescriptor getOutputDescriptor(UROutputDescriptor urOutputDescriptor) {
String source = urOutputDescriptor.getSource();
List<RegistryItem> keys = urOutputDescriptor.getKeys();
Map<ExtendedKey, String> mapExtendedPublicKeyLabels = new LinkedHashMap<>();
for(int i = 0; i < keys.size(); i++) {
RegistryItem key = keys.get(i);
if(key instanceof URHDKey urhdKey) {
ExtendedKey extendedKey = getExtendedKey(urhdKey);
KeyDerivation keyDerivation = getKeyDerivation(urhdKey.getOrigin());
source = source.replaceAll("@" + i, OutputDescriptor.writeKey(extendedKey, keyDerivation, null, true, true));
if(urhdKey.getName() != null) {
mapExtendedPublicKeyLabels.put(extendedKey, urhdKey.getName());
}
} else {
throw new IllegalArgumentException("Only extended HD keys are supported in output descriptors");
}
}
return OutputDescriptor.getOutputDescriptor(source, mapExtendedPublicKeyLabels);
}
private List<Wallet> getWallets(URAccountDescriptor urAccountDescriptor) {
List<Wallet> wallets = new ArrayList<>();
String masterFingerprint = Utils.bytesToHex(urAccountDescriptor.getMasterFingerprint());
for(UROutputDescriptor urOutputDescriptor : urAccountDescriptor.getOutputDescriptors()) {
OutputDescriptor outputDescriptor = getOutputDescriptor(urOutputDescriptor);
Wallet wallet = outputDescriptor.toKeystoreWallet(masterFingerprint);
wallets.add(wallet);
}
return wallets;
}
}
private class QRScanListener implements WebcamListener {
@@ -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);
@@ -12,7 +12,7 @@ import java.util.List;
public class SeedEntryDialog extends Dialog<List<String>> {
private final MnemonicKeystoreEntryPane keystorePane;
public SeedEntryDialog(int numWords) {
public SeedEntryDialog(String name, int numWords) {
final DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
@@ -38,7 +38,7 @@ public class SeedEntryDialog extends Dialog<List<String>> {
Accordion keystoreAccordion = new Accordion();
scrollPane.setContent(keystoreAccordion);
keystorePane = new MnemonicKeystoreEntryPane(numWords);
keystorePane = new MnemonicKeystoreEntryPane(name, numWords);
keystorePane.setAnimated(false);
keystoreAccordion.getPanes().add(keystorePane);
@@ -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);
}
@@ -85,6 +85,7 @@ public class TextAreaDialog extends Dialog<String> {
dialogPane.setPrefWidth(700);
dialogPane.setPrefHeight(400);
setResizable(true);
AppServices.moveToActiveWindowScreen(this);
}
@@ -109,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();
@@ -14,6 +14,7 @@ import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.event.SorobanInitiatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
@@ -44,7 +45,6 @@ import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
@@ -56,6 +56,8 @@ import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.glyphfont.GlyphUtils.*;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
private static final int REDUCED_MAX_UTXOS = MAX_UTXOS - 2;
@@ -72,6 +74,7 @@ public class TransactionDiagram extends GridPane {
private WalletTransaction walletTx;
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<TransactionDiagramLabel> labelProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
private boolean expanded;
private TransactionDiagram expandedDiagram;
@@ -154,6 +157,10 @@ public class TransactionDiagram extends GridPane {
updateDerivedDiagram(expandedDiagram);
}
}
if(getLabel() != null) {
getLabel().update(this);
}
}
public void update(String message) {
@@ -534,8 +541,9 @@ public class TransactionDiagram extends GridPane {
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
}
private String getSatsValue(long amount) {
return String.format(Locale.ENGLISH, "%,d", amount);
String getSatsValue(long amount) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.formatSatsValue(amount);
}
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -636,7 +644,7 @@ public class TransactionDiagram extends GridPane {
double width = 140.0;
long sum = walletTx.getTotal();
List<Long> values = walletTx.getTransaction().getOutputs().stream().map(TransactionOutput::getValue).collect(Collectors.toList());
List<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
values.add(walletTx.getFee());
int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
for(int i = 1; i <= numOutputs; i++) {
@@ -680,18 +688,18 @@ public class TransactionDiagram extends GridPane {
List<OutputNode> outputNodes = new ArrayList<>();
for(Payment payment : displayedPayments) {
Glyph outputGlyph = getOutputGlyph(payment);
Glyph outputGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment;
payment.setLabel(getOutputLabel(payment));
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = getToWallet(payment);
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Wallet toBip47Wallet = getBip47SendWallet(payment);
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : ""));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
@@ -713,6 +721,7 @@ public class TransactionDiagram extends GridPane {
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount()));
}
Set<Integer> seenIndexes = new HashSet<>();
for(Map.Entry<WalletNode, Long> changeEntry : walletTx.getChangeMap().entrySet()) {
WalletNode changeNode = changeEntry.getKey();
WalletNode defaultChangeNode = walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE);
@@ -758,11 +767,15 @@ public class TransactionDiagram extends GridPane {
actionBox.getChildren().addAll(region, amountLabel);
}
outputNodes.add(new OutputNode(actionBox, changeAddress, changeEntry.getValue()));
}
if(isFinal()) {
Collections.sort(outputNodes);
int changeIndex = outputNodes.size();
if(isFinal()) {
changeIndex = getOutputIndex(changeAddress, changeEntry.getValue(), seenIndexes);
seenIndexes.add(changeIndex);
if(changeIndex > outputNodes.size()) {
changeIndex = outputNodes.size();
}
}
outputNodes.add(changeIndex, new OutputNode(actionBox, changeAddress, changeEntry.getValue()));
}
for(OutputNode outputNode : outputNodes) {
@@ -916,38 +929,10 @@ public class TransactionDiagram extends GridPane {
return spacer;
}
private String getOutputLabel(Payment payment) {
if(payment.getLabel() != null) {
return payment.getLabel();
}
if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) {
return "Whirlpool Fee";
} else if(walletTx.isPremixSend(payment)) {
int premixIndex = getOutputIndex(payment.getAddress(), payment.getAmount()) - 2;
return "Premix #" + premixIndex;
} else if(walletTx.isBadbankSend(payment)) {
return "Badbank Change";
}
return null;
}
private int getOutputIndex(Address address, long amount) {
return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow();
}
private Wallet getToWallet(Payment payment) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet != walletTx.getWallet() && openWallet.isValid()) {
WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress());
if(addressNode != null) {
return addressNode.getWallet();
}
}
}
return null;
private int getOutputIndex(Address address, long amount, Collection<Integer> seenIndexes) {
List<TransactionOutput> addressOutputs = walletTx.getTransaction().getOutputs().stream().filter(txOutput -> txOutput.getScript().getToAddress() != null).collect(Collectors.toList());
TransactionOutput output = addressOutputs.stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex())).findFirst().orElseThrow();
return addressOutputs.indexOf(output);
}
private Wallet getBip47SendWallet(Payment payment) {
@@ -967,151 +952,6 @@ public class TransactionDiagram extends GridPane {
return null;
}
public Glyph getOutputGlyph(Payment payment) {
if(payment.getType().equals(Payment.Type.MIX)) {
return getMixGlyph();
} else if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
return getFakeMixGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
return getConsolidationGlyph();
} else if(walletTx.isPremixSend(payment)) {
return getPremixGlyph();
} else if(walletTx.isBadbankSend(payment)) {
return getBadbankGlyph();
} else if(payment.getType().equals(Payment.Type.WHIRLPOOL_FEE)) {
return getWhirlpoolFeeGlyph();
} else if(payment instanceof AdditionalPayment) {
return ((AdditionalPayment)payment).getOutputGlyph(this);
} else if(getToWallet(payment) != null) {
return getDepositGlyph();
}
return getPaymentGlyph();
}
public static Glyph getExcludeGlyph() {
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
excludeGlyph.getStyleClass().add("exclude-utxo");
excludeGlyph.setFontSize(12);
return excludeGlyph;
}
public static Glyph getPaymentGlyph() {
Glyph paymentGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
paymentGlyph.getStyleClass().add("payment-icon");
paymentGlyph.setFontSize(12);
return paymentGlyph;
}
public static Glyph getConsolidationGlyph() {
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
consolidationGlyph.getStyleClass().add("consolidation-icon");
consolidationGlyph.setFontSize(12);
return consolidationGlyph;
}
public static Glyph getDepositGlyph() {
Glyph depositGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
depositGlyph.getStyleClass().add("deposit-icon");
depositGlyph.setFontSize(12);
return depositGlyph;
}
public static Glyph getPremixGlyph() {
Glyph premixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
premixGlyph.getStyleClass().add("premix-icon");
premixGlyph.setFontSize(12);
return premixGlyph;
}
public static Glyph getBadbankGlyph() {
Glyph badbankGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BIOHAZARD);
badbankGlyph.getStyleClass().add("badbank-icon");
badbankGlyph.setFontSize(12);
return badbankGlyph;
}
public static Glyph getWhirlpoolFeeGlyph() {
Glyph whirlpoolFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_WATER);
whirlpoolFeeGlyph.getStyleClass().add("whirlpoolfee-icon");
whirlpoolFeeGlyph.setFontSize(12);
return whirlpoolFeeGlyph;
}
public static Glyph getFakeMixGlyph() {
Glyph fakeMixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.THEATER_MASKS);
fakeMixGlyph.getStyleClass().add("fakemix-icon");
fakeMixGlyph.setFontSize(12);
return fakeMixGlyph;
}
public static Glyph getTxoGlyph() {
return getChangeGlyph();
}
public static Glyph getMixGlyph() {
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
payjoinGlyph.getStyleClass().add("mix-icon");
payjoinGlyph.setFontSize(12);
return payjoinGlyph;
}
public static Glyph getChangeGlyph() {
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
changeGlyph.getStyleClass().add("change-icon");
changeGlyph.setFontSize(12);
return changeGlyph;
}
public static Glyph getChangeWarningGlyph() {
Glyph changeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
changeWarningGlyph.getStyleClass().add("change-warning-icon");
changeWarningGlyph.setFontSize(12);
return changeWarningGlyph;
}
public static Glyph getChangeReplaceGlyph() {
Glyph changeReplaceGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
changeReplaceGlyph.getStyleClass().add("change-replace-icon");
changeReplaceGlyph.setFontSize(12);
return changeReplaceGlyph;
}
private Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
feeGlyph.getStyleClass().add("fee-icon");
feeGlyph.setFontSize(12);
return feeGlyph;
}
private Glyph getWarningGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getQuestionGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QUESTION_CIRCLE);
feeWarningGlyph.getStyleClass().add("question-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getLockGlyph() {
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
lockGlyph.getStyleClass().add("lock-icon");
lockGlyph.setFontSize(12);
return lockGlyph;
}
private Glyph getUserGlyph() {
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER);
userGlyph.getStyleClass().add("user-icon");
userGlyph.setFontSize(12);
return userGlyph;
}
private Glyph getUserAddGlyph() {
Glyph userAddGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER_PLUS);
userAddGlyph.getStyleClass().add("useradd-icon");
@@ -1161,6 +1001,10 @@ public class TransactionDiagram extends GridPane {
}
}
public WalletTransaction getWalletTransaction() {
return walletTx;
}
public boolean isFinal() {
return finalProperty.get();
}
@@ -1173,6 +1017,18 @@ public class TransactionDiagram extends GridPane {
this.finalProperty.set(isFinal);
}
public TransactionDiagramLabel getLabel() {
return labelProperty.get();
}
public ObjectProperty<TransactionDiagramLabel> labelProperty() {
return labelProperty;
}
public void setLabelProperty(TransactionDiagramLabel label) {
this.labelProperty.set(label);
}
public OptimizationStrategy getOptimizationStrategy() {
return optimizationStrategyProperty.get();
}
@@ -1252,7 +1108,7 @@ public class TransactionDiagram extends GridPane {
}
}
private static class AdditionalPayment extends Payment {
public static class AdditionalPayment extends Payment {
private final List<Payment> additionalPayments;
public AdditionalPayment(List<Payment> additionalPayments) {
@@ -1260,10 +1116,10 @@ public class TransactionDiagram extends GridPane {
this.additionalPayments = additionalPayments;
}
public Glyph getOutputGlyph(TransactionDiagram transactionDiagram) {
public Glyph getOutputGlyph(WalletTransaction walletTx) {
Glyph glyph = null;
for(Payment payment : additionalPayments) {
Glyph paymentGlyph = transactionDiagram.getOutputGlyph(payment);
Glyph paymentGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
if(glyph != null && !paymentGlyph.getStyleClass().equals(glyph.getStyleClass())) {
return getPaymentGlyph();
}
@@ -1279,7 +1135,7 @@ public class TransactionDiagram extends GridPane {
}
}
private class OutputNode implements Comparable<OutputNode> {
private static class OutputNode {
public Pane outputLabel;
public Address address;
public long amount;
@@ -1289,18 +1145,9 @@ public class TransactionDiagram extends GridPane {
this.address = address;
this.amount = amount;
}
@Override
public int compareTo(TransactionDiagram.OutputNode o) {
try {
return getOutputIndex(address, amount) - getOutputIndex(o.address, o.amount);
} catch(Exception e) {
return 0;
}
}
}
private static class LabelContextMenu extends ContextMenu {
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
@@ -1316,6 +1163,7 @@ public class TransactionDiagram extends GridPane {
showAddress.setOnAction(event -> {
hide();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
qrDisplayDialog.initOwner(TransactionDiagram.this.getScene().getWindow());
qrDisplayDialog.showAndWait();
});
getItems().add(showAddress);
@@ -0,0 +1,270 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.text.Font;
import org.controlsfx.glyphfont.Glyph;
import java.util.*;
import java.util.stream.Collectors;
public class TransactionDiagramLabel extends HBox {
private final List<HBox> outputs = new ArrayList<>();
private final Button left;
private final Button right;
private final IntegerProperty displayedIndex = new SimpleIntegerProperty(-1);
public TransactionDiagramLabel() {
setSpacing(5);
setAlignment(Pos.CENTER_RIGHT);
left = new Button("");
left.setGraphic(getLeftGlyph());
left.setOnAction(event -> {
int index = displayedIndex.get();
if(index > 0) {
index--;
}
displayedIndex.set(index);
});
right = new Button("");
right.setGraphic(getRightGlyph());
right.setOnAction(event -> {
int index = displayedIndex.get();
if(index < outputs.size() - 1) {
index++;
}
displayedIndex.set(index);
});
displayedIndex.addListener((observable, oldValue, newValue) -> {
left.setDisable(newValue.intValue() <= 0);
right.setDisable(newValue.intValue() < 0 || newValue.intValue() >= outputs.size() - 1);
if(oldValue.intValue() >= 0 && oldValue.intValue() < outputs.size()) {
outputs.get(oldValue.intValue()).setVisible(false);
}
if(newValue.intValue() >= 0 && newValue.intValue() < outputs.size()) {
outputs.get(newValue.intValue()).setVisible(true);
}
});
}
public void update(TransactionDiagram transactionDiagram) {
getChildren().clear();
outputs.clear();
displayedIndex.set(-1);
double maxWidth = getMaxWidth();
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
List<OutputLabel> outputLabels = new ArrayList<>();
List<Payment> premixOutputs = walletTx.getPayments().stream().filter(walletTx::isPremixSend).collect(Collectors.toList());
if(!premixOutputs.isEmpty()) {
OutputLabel premixOutputLabel = getPremixOutputLabel(transactionDiagram, premixOutputs);
if(premixOutputLabel != null) {
outputLabels.add(premixOutputLabel);
}
Optional<Payment> optWhirlpoolFee = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.WHIRLPOOL_FEE).findFirst();
if(optWhirlpoolFee.isPresent()) {
OutputLabel whirlpoolFeeOutputLabel = getWhirlpoolFeeOutputLabel(transactionDiagram, optWhirlpoolFee.get(), premixOutputs);
outputLabels.add(whirlpoolFeeOutputLabel);
}
List<Payment> badbankOutputs = walletTx.getPayments().stream().filter(walletTx::isBadbankSend).collect(Collectors.toList());
List<OutputLabel> badbankOutputLabels = badbankOutputs.stream().map(payment -> getBadbankOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
outputLabels.addAll(badbankOutputLabels);
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_PREMIX && walletTx.getPayments().stream().anyMatch(walletTx::isPostmixSend)) {
OutputLabel mixOutputLabel = getMixOutputLabel(transactionDiagram, walletTx.getPayments());
if(mixOutputLabel != null) {
outputLabels.add(mixOutputLabel);
}
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
if(remixOutputLabel != null) {
outputLabels.add(remixOutputLabel);
}
} else {
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
}
outputLabels.addAll(paymentLabels);
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
outputLabels.addAll(mixes.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()));
}
Map<WalletNode, Long> changeMap = walletTx.getChangeMap();
outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList()));
OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram);
if(feeOutputLabel != null) {
outputLabels.add(feeOutputLabel);
}
for(OutputLabel outputLabel : outputLabels) {
maxWidth = Math.max(maxWidth, outputLabel.width);
outputs.add(outputLabel.hBox);
getChildren().add(outputLabel.hBox);
}
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER_RIGHT);
buttonBox.getChildren().addAll(left, right);
getChildren().add(buttonBox);
setMaxWidth(maxWidth);
setPrefWidth(maxWidth);
if(outputLabels.size() > 0) {
displayedIndex.set(0);
}
}
private OutputLabel getPremixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> premixOutputs) {
if(premixOutputs.isEmpty()) {
return null;
}
Payment premixOutput = premixOutputs.get(0);
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), premixOutput);
String text;
if(premixOutputs.size() == 1) {
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
} else {
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
}
return getOutputLabel(glyph, text);
}
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getWhirlpoolFeeOutputLabel(TransactionDiagram transactionDiagram, Payment whirlpoolFee, List<Payment> premixOutputs) {
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), whirlpoolFee);
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
return getOutputLabel(glyph, text);
}
private OutputLabel getMixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> mixOutputs) {
if(mixOutputs.isEmpty()) {
return null;
}
Payment remixOutput = mixOutputs.get(0);
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getPremixGlyph();
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
return getOutputLabel(glyph, text);
}
private OutputLabel getRemixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> remixOutputs) {
if(remixOutputs.isEmpty()) {
return null;
}
Payment remixOutput = remixOutputs.get(0);
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
Glyph glyph = GlyphUtils.getPremixGlyph();
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
+ transactionDiagram.getSatsValue(total) + " sats)";
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Map.Entry<WalletNode, Long> changeEntry) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Glyph glyph = GlyphUtils.getChangeGlyph();
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
return getOutputLabel(glyph, text);
}
private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
if(walletTx.getFee() < 0) {
return null;
}
Glyph glyph = GlyphUtils.getFeeGlyph();
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)";
return getOutputLabel(glyph, text);
}
private OutputLabel getOutputLabel(Glyph glyph, String text) {
Label icon = new Label();
icon.setMinWidth(15);
glyph.setFontSize(12);
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setFont(Font.font("Roboto Mono Italic", 13));
label.setText(text);
HBox output = new HBox(5);
output.setAlignment(Pos.CENTER);
output.managedProperty().bind(output.visibleProperty());
output.setVisible(false);
output.getChildren().addAll(icon, label);
double lineWidth = TextUtils.computeTextWidth(label.getFont(), label.getText(), 0.0D) + 2 + getSpacing() + icon.getMinWidth() + 60;
return new OutputLabel(output, lineWidth, text);
}
public static Glyph getLeftGlyph() {
Glyph caretLeftGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_LEFT);
caretLeftGlyph.getStyleClass().add("label-left-icon");
caretLeftGlyph.setFontSize(15);
return caretLeftGlyph;
}
public static Glyph getRightGlyph() {
Glyph caretRightGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_RIGHT);
caretRightGlyph.getStyleClass().add("label-right-icon");
caretRightGlyph.setFontSize(15);
return caretRightGlyph;
}
private record OutputLabel(HBox hBox, double width, String text) {}
}
@@ -50,8 +50,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
getSortOrder().add(dateCol);
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateAll(WalletTransactionsEntry rootEntry) {
@@ -61,16 +60,13 @@ public class TransactionsTreeTable extends CoinTreeTable {
setRoot(rootItem);
rootItem.setExpanded(true);
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
TreeTableColumn<Entry, ?> dateCol = getColumns().get(0);
getSortOrder().add(dateCol);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
}
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateHistory() {
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
sort();
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
}
public void updateLabel(Entry entry) {
@@ -0,0 +1,46 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.control.TreeTableColumn;
public class TreeTableColumnConfig {
private final int index;
private final Integer width;
private final TreeTableColumn.SortType sortType;
public TreeTableColumnConfig(int index, Integer width, TreeTableColumn.SortType sortType) {
this.index = index;
this.width = width;
this.sortType = sortType;
}
public TreeTableColumnConfig(int index, String width, String sortType) {
this.index = index;
this.width = width.isEmpty() ? null : Integer.valueOf(width, 10);
this.sortType = sortType.isEmpty() ? null : TreeTableColumn.SortType.valueOf(sortType);
}
public int getIndex() {
return index;
}
public Integer getWidth() {
return width;
}
public TreeTableColumn.SortType getSortType() {
return sortType;
}
public String toString() {
return index + "-" + (width == null ? "" : width) + "-" + (sortType == null ? "" : sortType);
}
public static TreeTableColumnConfig fromString(String columnConfig) {
String[] parts = columnConfig.split("-", 3);
if(parts.length == 3) {
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), parts[1], parts[2]);
}
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), (Integer)null, null);
}
}
@@ -0,0 +1,42 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.scene.control.TreeTableColumn;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
public class TreeTableConfig {
private final List<TreeTableColumnConfig> columnConfigs;
public TreeTableConfig(CoinTreeTable treeTable) {
columnConfigs = new ArrayList<>();
TreeTableColumn<Entry, ?> sortColumn = treeTable.getSortOrder().isEmpty() ? null : treeTable.getSortOrder().get(0);
for(int i = 0; i < treeTable.getColumns().size(); i++) {
TreeTableColumn<Entry, ?> column = treeTable.getColumns().get(i);
//TODO: Support column widths
columnConfigs.add(new TreeTableColumnConfig(i, null, sortColumn == column ? column.getSortType() : null));
}
}
public TreeTableConfig(List<TreeTableColumnConfig> columnConfigs) {
this.columnConfigs = columnConfigs;
}
public String toString() {
StringJoiner joiner = new StringJoiner("|");
columnConfigs.stream().forEach(col -> joiner.add(col.toString()));
return joiner.toString();
}
public static TreeTableConfig fromString(String tableConfig) {
List<TreeTableColumnConfig> columnConfigs = new ArrayList<>();
String[] parts = tableConfig.split("\\|");
for(String part : parts) {
columnConfigs.add(TreeTableColumnConfig.fromString(part));
}
return new TreeTableConfig(columnConfigs);
}
}
@@ -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);
}
}
@@ -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(), new WalletLabels());
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 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());
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);
@@ -51,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(), new AirGapVault());
for(KeystoreFileImport importer : keystoreImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
@@ -59,7 +59,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
}
}
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()));
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));
}
@@ -90,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);
@@ -42,9 +42,13 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
}
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:");
@@ -121,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();
}
@@ -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,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,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;
}
}
@@ -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() {
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WhirlpoolIndexHighFrequencyEvent {
private final Wallet wallet;
public WhirlpoolIndexHighFrequencyEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -25,6 +25,8 @@ public class FontAwesome5 extends GlyphFont {
BTC('\uf15a'),
BULLSEYE('\uf140'),
CAMERA('\uf030'),
CARET_LEFT('\uf0d9'),
CARET_RIGHT('\uf0da'),
CHECK_CIRCLE('\uf058'),
CIRCLE('\uf111'),
COINS('\uf51e'),
@@ -49,8 +51,10 @@ public class FontAwesome5 extends GlyphFont {
LINK('\uf0c1'),
LOCK('\uf023'),
LOCK_OPEN('\uf3c1'),
LONG_ARROW_ALT_RIGHT('\uf30b'),
MAGNIFYING_GLASS_PLUS('\uf00e'),
MAGNIFYING_GLASS_MINUS('\uf010'),
MICROCHIP('\uf2db'),
MINUS_CIRCLE('\uf056'),
PEN_FANCY('\uf5ac'),
PLUS('\uf067'),
@@ -0,0 +1,178 @@
package com.sparrowwallet.sparrow.glyphfont;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
public class GlyphUtils {
public static Glyph getOutputGlyph(WalletTransaction walletTx, Payment payment) {
if(payment.getType().equals(Payment.Type.MIX)) {
return getMixGlyph();
} else if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
return getFakeMixGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
return getConsolidationGlyph();
} else if(walletTx.isPremixSend(payment)) {
return getPremixGlyph();
} else if(walletTx.isBadbankSend(payment)) {
return getBadbankGlyph();
} else if(payment.getType().equals(Payment.Type.WHIRLPOOL_FEE)) {
return getWhirlpoolFeeGlyph();
} else if(payment instanceof TransactionDiagram.AdditionalPayment) {
return ((TransactionDiagram.AdditionalPayment)payment).getOutputGlyph(walletTx);
} else if(walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment) != null) {
return getDepositGlyph();
} else if(walletTx.isDuplicateAddress(payment)) {
return getPaymentWarningGlyph();
}
return getPaymentGlyph();
}
public static Glyph getPaymentGlyph() {
Glyph paymentGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
paymentGlyph.getStyleClass().add("payment-icon");
paymentGlyph.setFontSize(12);
return paymentGlyph;
}
public static Glyph 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");
consolidationGlyph.setFontSize(12);
return consolidationGlyph;
}
public static Glyph getDepositGlyph() {
Glyph depositGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
depositGlyph.getStyleClass().add("deposit-icon");
depositGlyph.setFontSize(12);
return depositGlyph;
}
public static Glyph getPremixGlyph() {
Glyph premixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
premixGlyph.getStyleClass().add("premix-icon");
premixGlyph.setFontSize(12);
return premixGlyph;
}
public static Glyph getBadbankGlyph() {
Glyph badbankGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BIOHAZARD);
badbankGlyph.getStyleClass().add("badbank-icon");
badbankGlyph.setFontSize(12);
return badbankGlyph;
}
public static Glyph getWhirlpoolFeeGlyph() {
Glyph whirlpoolFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_WATER);
whirlpoolFeeGlyph.getStyleClass().add("whirlpoolfee-icon");
whirlpoolFeeGlyph.setFontSize(12);
return whirlpoolFeeGlyph;
}
public static Glyph getFakeMixGlyph() {
Glyph fakeMixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.THEATER_MASKS);
fakeMixGlyph.getStyleClass().add("fakemix-icon");
fakeMixGlyph.setFontSize(12);
return fakeMixGlyph;
}
public static Glyph getTxoGlyph() {
return getChangeGlyph();
}
public static Glyph getMixGlyph() {
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
payjoinGlyph.getStyleClass().add("mix-icon");
payjoinGlyph.setFontSize(12);
return payjoinGlyph;
}
public static Glyph getExternalInputGlyph() {
Glyph externalGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LONG_ARROW_ALT_RIGHT);
externalGlyph.getStyleClass().add("external-input-icon");
externalGlyph.setFontSize(12);
return externalGlyph;
}
public static Glyph getExcludeGlyph() {
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
excludeGlyph.getStyleClass().add("exclude-utxo");
excludeGlyph.setFontSize(12);
return excludeGlyph;
}
public static Glyph getChangeGlyph() {
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
changeGlyph.getStyleClass().add("change-icon");
changeGlyph.setFontSize(12);
return changeGlyph;
}
public static Glyph getChangeWarningGlyph() {
Glyph changeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
changeWarningGlyph.getStyleClass().add("change-warning-icon");
changeWarningGlyph.setFontSize(12);
return changeWarningGlyph;
}
public static Glyph getChangeReplaceGlyph() {
Glyph changeReplaceGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
changeReplaceGlyph.getStyleClass().add("change-replace-icon");
changeReplaceGlyph.setFontSize(12);
return changeReplaceGlyph;
}
public static Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
feeGlyph.getStyleClass().add("fee-icon");
feeGlyph.setFontSize(12);
return feeGlyph;
}
public static Glyph getWarningGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
public static Glyph getQuestionGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QUESTION_CIRCLE);
feeWarningGlyph.getStyleClass().add("question-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
public static Glyph getLockGlyph() {
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
lockGlyph.getStyleClass().add("lock-icon");
lockGlyph.setFontSize(12);
return lockGlyph;
}
public static Glyph getUserGlyph() {
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER);
userGlyph.getStyleClass().add("user-icon");
userGlyph.setFontSize(12);
return userGlyph;
}
public static Glyph getOpcodeGlyph() {
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.MICROCHIP);
userGlyph.getStyleClass().add("opcode-icon");
userGlyph.setFontSize(12);
return userGlyph;
}
}
@@ -0,0 +1,35 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public class AirGapVault extends KeystoneSinglesig {
@Override
public String getName() {
return "AirGap Vault";
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your AirGap Vault, by selecting the Secret > Account > Sparrow Wallet";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.AIRGAP_VAULT;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
@Override
public boolean isWalletImportFileFormatAvailable() {
return false;
}
@Override
public boolean isDeprecated() {
return true;
}
}
@@ -189,7 +189,7 @@ public class Bip129 implements KeystoreFileExport, KeystoreFileImport, WalletExp
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
String record = "BSMS 1.0\n" +
OutputDescriptor.getOutputDescriptor(wallet) +
@@ -28,7 +28,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
@Override
public String getName() {
return "Caravan Multisig";
return "Unchained or Caravan Multisig";
}
@Override
@@ -94,7 +94,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
if(!wallet.isValid()) {
throw new ExportException("Cannot export an incomplete wallet");
}
@@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
import com.sparrowwallet.sparrow.io.satochip.SatoCardApi;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import org.controlsfx.tools.Platform;
@@ -17,11 +18,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import javax.smartcardio.CardTerminal;
import javax.smartcardio.CardTerminals;
import javax.smartcardio.TerminalFactory;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -32,19 +35,30 @@ public abstract class CardApi {
new File("/usr/lib/libpcsclite.so.1"),
new File("/usr/local/lib/libpcsclite.so.1"),
new File("/lib/x86_64-linux-gnu/libpcsclite.so.1"),
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1")};
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1"),
new File("/usr/lib64/libpcsclite.so.1"),
new File("/usr/lib/x86_64-linux-gnu/libpcsclite.so.1")};
private static boolean initialized;
public static List<WalletModel> getConnectedCards() throws CardException {
List<WalletModel> cards = new ArrayList<>();
try {
CkCardApi ckCardApi = new CkCardApi(null, null);
return List.of(ckCardApi.getCardType());
cards.add(ckCardApi.getCardType());
} catch(Exception e) {
//ignore
}
return Collections.emptyList();
try {
SatoCardApi satoCardApi = new SatoCardApi(null, null);
cards.add(satoCardApi.getCardType());
} catch(Exception e) {
//ignore
}
return cards;
}
public static CardApi getCardApi(WalletModel walletModel, String pin) throws CardException {
@@ -52,6 +66,10 @@ public abstract class CardApi {
return new CkCardApi(walletModel, pin);
}
if(walletModel == WalletModel.SATOCHIP) {
return new SatoCardApi(walletModel, pin);
}
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
}
@@ -90,11 +108,15 @@ public abstract class CardApi {
public abstract void disconnect();
public static boolean isReaderAvailable() {
return !getAvailableTerminals().isEmpty();
}
public static List<CardTerminal> getAvailableTerminals() {
setLibrary();
try {
TerminalFactory tf = TerminalFactory.getDefault();
return !tf.terminals().list().isEmpty();
return tf.terminals().list();
} catch(Exception e) {
Throwable cause = Throwables.getRootCause(e);
if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) {
@@ -106,7 +128,7 @@ public abstract class CardApi {
}
}
return false;
return Collections.emptyList();
}
private static void recoverNoService() {
@@ -6,5 +6,5 @@ import javax.smartcardio.CardException;
public interface CardImport extends ImportExport {
boolean isInitialized() throws CardException;
void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException;
void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException;
}
@@ -165,7 +165,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
if(!wallet.isValid()) {
throw new ExportException("Cannot export an incomplete wallet");
}
@@ -58,6 +58,7 @@ public class Config {
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private Boolean hdCapture;
private boolean useZbar = true;
private String webcamDevice;
private ServerType serverType;
private Server publicElectrumServer;
@@ -77,6 +78,7 @@ public class Config {
private int maxPageSize = DEFAULT_PAGE_SIZE;
private boolean usePayNym;
private boolean sameAppMixing;
private boolean mempoolFullRbf;
private Double appWidth;
private Double appHeight;
@@ -404,6 +406,10 @@ public class Config {
flush();
}
public boolean isUseZbar() {
return useZbar;
}
public String getWebcamDevice() {
return webcamDevice;
}
@@ -665,6 +671,15 @@ public class Config {
flush();
}
public boolean isMempoolFullRbf() {
return mempoolFullRbf;
}
public void setMempoolFullRbf(boolean mempoolFullRbf) {
this.mempoolFullRbf = mempoolFullRbf;
flush();
}
public Double getAppWidth() {
return appWidth;
}
@@ -23,7 +23,7 @@ public class Descriptor implements WalletImport, WalletExport {
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream));
bufferedWriter.write("# Receive and change descriptor (BIP389):");
@@ -65,7 +65,7 @@ public class Descriptor implements WalletImport, WalletExport {
@Override
public boolean isWalletExportScannable() {
return false;
return true;
}
@Override
@@ -2,10 +2,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.google.gson.reflect.TypeToken;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.Policy;
@@ -18,7 +15,10 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.*;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
public class Electrum implements KeystoreFileImport, WalletImport, WalletExport {
@@ -301,11 +301,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
@Override
public String getExportFileExtension(Wallet wallet) {
return "json";
return wallet.isEncrypted() ? "" : "json";
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
ElectrumJsonWallet ew = new ElectrumJsonWallet();
if(wallet.getPolicyType().equals(PolicyType.SINGLE)) {
@@ -342,7 +342,18 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
ek.seed = keystore.getSeed().getMnemonicString().asString();
ek.passphrase = keystore.getSeed().getPassphrase() == null ? null : keystore.getSeed().getPassphrase().asString();
}
ew.use_encryption = false;
if(password != null) {
ek.xprv = encrypt(ek.xprv, password);
if(ek.seed != null) {
ek.seed = encrypt(ek.seed, password);
}
if(ek.passphrase != null) {
ek.passphrase = encrypt(ek.passphrase, password);
}
ew.use_encryption = true;
} else {
ew.use_encryption = false;
}
} else if(keystore.getSource() == KeystoreSource.SW_WATCH) {
ek.type = "bip32";
ek.xpub = keystore.getExtendedPublicKey().toString(xpubHeader);
@@ -373,14 +384,41 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
String json = gson.toJson(eJson);
if(password != null) {
ECKey encryptionKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey(password);
outputStream = new DeflaterOutputStream(new ECIESOutputStream(outputStream, encryptionKey));
}
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
} catch (Exception e) {
log.error("Error exporting Electrum Wallet", e);
throw new ExportException("Error exporting Electrum Wallet", e);
}
}
private String encrypt(String plain, String password) throws NoSuchAlgorithmException {
if(plain == null) {
return null;
}
byte[] plainBytes = plain.getBytes(StandardCharsets.UTF_8);
KeyDeriver keyDeriver = new DoubleSha256KeyDeriver();
Key key = keyDeriver.deriveKey(password);
KeyCrypter keyCrypter = new AESKeyCrypter();
byte[] initializationVector = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(initializationVector);
EncryptedData encryptedData = keyCrypter.encrypt(plainBytes, initializationVector, key);
byte[] encrypted = new byte[initializationVector.length + encryptedData.getEncryptedBytes().length];
System.arraycopy(initializationVector, 0, encrypted, 0, 16);
System.arraycopy(encryptedData.getEncryptedBytes(), 0, encrypted, 16, encryptedData.getEncryptedBytes().length);
byte[] encryptedBase64 = Base64.getEncoder().encode(encrypted);
return new String(encryptedBase64, StandardCharsets.UTF_8);
}
@Override
public boolean isEncrypted(File file) {
return (FileType.BINARY.equals(IOUtils.getFileType(file)));
@@ -29,7 +29,7 @@ public class ElectrumPersonalServer implements WalletExport {
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
if(wallet.getScriptType() == ScriptType.P2TR) {
throw new ExportException(getName() + " does not support Taproot wallets.");
}
@@ -36,7 +36,7 @@ public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.2.1";
private static final String HWI_VERSION = "2.3.1";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -1,8 +1,11 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class JadeMultisig extends ColdcardMultisig {
@Override
public String getName() {
@@ -38,4 +41,30 @@ public class JadeMultisig extends ColdcardMultisig {
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public String getWalletImportDescription() {
return "Import the QR created using Options > Wallet > Registered Wallets on your Jade.";
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isWalletImportFileFormatAvailable() {
return false;
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
Wallet wallet = super.importWallet(inputStream, password);
for(Keystore keystore : wallet.getKeystores()) {
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Jade"));
keystore.setWalletModel(WalletModel.JADE);
}
return wallet;
}
}
@@ -0,0 +1,25 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public class Krux extends SpecterDIY {
@Override
public String getName() {
return "Krux";
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import file or QR created on your Krux by selecting Extended Public Key from the main menu once you have loaded your mnemonic.";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KRUX;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.io;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
@@ -104,7 +105,7 @@ public class PdfUtils {
private static javafx.scene.image.Image getQrCode(String fragment) throws IOException, WriterException {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT, Map.of(EncodeHintType.MARGIN, "2"));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
@@ -157,7 +158,7 @@ public class PdfUtils {
Font headerFont = new Font(Font.HELVETICA, 8, Font.BOLD, Color.DARK_GRAY);
Font font = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.DARK_GRAY);
HeaderFooter footer = new HeaderFooter(false, new Phrase("Recovery Phrase: " + String.join(" ", mnemonicWords), font));
HeaderFooter footer = new HeaderFooter(false, new Phrase("Recovery Phrase (to regenerate grid): " + String.join(" ", mnemonicWords), font));
footer.setAlignment(Element.ALIGN_CENTER);
footer.setBorder(Rectangle.NO_BORDER);
footer.setBorderWidth(0);
@@ -210,6 +211,8 @@ public class PdfUtils {
log.error("Error creating word grid PDF", e);
AppServices.showErrorDialog("Error creating word grid PDF", e.getMessage());
}
} else {
AppServices.showWarningDialog("Entropy Grid PDF not saved", "You have chosen to not save the entropy grid PDF.\n\nDo not store funds on a seed selected from this grid - you will not be able to regenerate it!");
}
}
}
@@ -10,7 +10,7 @@ public class SeedSigner extends SpecterDIY {
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your SeedSigner by selecting xPub from Seed in the Seed Tools menu once you have entered your seed.";
return "Import QR created on your SeedSigner by selecting Export Xpub in the Seeds menu once you have entered your seed.";
}
@Override
@@ -28,7 +28,7 @@ public class Sparrow implements WalletImport, WalletExport {
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
Wallet exportedWallet = !wallet.isMasterWallet() ? wallet.getMasterWallet() : wallet;
PersistenceType persistenceType = PersistenceType.DB;
@@ -39,8 +39,10 @@ public class Sparrow implements WalletImport, WalletExport {
Storage tempStorage = new Storage(persistence, tempFile);
tempStorage.setKeyDeriver(storage.getKeyDeriver());
tempStorage.setEncryptionPubKey(storage.getEncryptionPubKey());
tempStorage.saveWallet(exportedWallet);
for(Wallet childWallet : exportedWallet.getChildWallets()) {
Wallet copy = exportedWallet.copy();
tempStorage.saveWallet(copy);
for(Wallet childWallet : copy.getChildWallets()) {
tempStorage.saveWallet(childWallet);
}
persistence.close();
@@ -65,7 +65,7 @@ public class SpecterDIY implements KeystoreFileImport, WalletExport {
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.append("addwallet ").append(wallet.getFullName()).append("&").append(OutputDescriptor.getOutputDescriptor(wallet).toString().replace('\'', 'h')).append("\n");
@@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.*;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
@@ -12,17 +11,18 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class SpecterDesktop implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(SpecterDesktop.class);
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
SpecterWallet specterWallet = new SpecterWallet();
specterWallet.label = wallet.getFullName();
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight());
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight() == null ? 0 : wallet.getStoredBlockHeight());
specterWallet.descriptor = OutputDescriptor.getOutputDescriptor(wallet).toString(true);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -54,7 +54,44 @@ public class SpecterDesktop implements WalletImport, WalletExport {
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
Gson gson = new Gson();
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), SpecterWallet.class);
JsonObject jsonObj = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), JsonElement.class).getAsJsonObject();
SpecterWallet specterWallet = new SpecterWallet();
if(jsonObj.get("descriptor") != null) {
specterWallet.descriptor = jsonObj.get("descriptor").getAsString();
} else if(jsonObj.get("recv_descriptor") != null) {
specterWallet.descriptor = jsonObj.get("recv_descriptor").getAsString();
}
if(jsonObj.get("label") != null) {
specterWallet.label = jsonObj.get("label").getAsString();
} else if(jsonObj.get("name") != null) {
specterWallet.label = jsonObj.get("name").getAsString();
}
if(jsonObj.get("blockheight") != null) {
specterWallet.blockheight = jsonObj.get("blockheight").getAsInt();
}
if(jsonObj.get("devices") != null) {
JsonArray jsonDevices = jsonObj.get("devices").getAsJsonArray();
specterWallet.devices = new ArrayList<>();
for(JsonElement jsonDevice : jsonDevices) {
SpecterWalletDevice specterWalletDevice = new SpecterWalletDevice();
if(jsonDevice.isJsonObject()) {
JsonObject jsonDeviceObj = (JsonObject)jsonDevice;
if(jsonDeviceObj.get("label") != null) {
specterWalletDevice.label = jsonDeviceObj.get("label").getAsString();
}
if(jsonDeviceObj.get("type") != null) {
specterWalletDevice.type = jsonDeviceObj.get("type").getAsString();
}
} else if(jsonDevice.isJsonPrimitive()) {
specterWalletDevice.label = jsonDevice.getAsString();
}
specterWallet.devices.add(specterWalletDevice);
}
}
if(specterWallet.descriptor != null) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(specterWallet.descriptor);
@@ -80,6 +117,9 @@ public class SpecterDesktop implements WalletImport, WalletExport {
} else {
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
}
} else {
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setSource(KeystoreSource.SW_WATCH);
}
}
}
@@ -8,7 +8,6 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.concurrent.ScheduledService;
@@ -265,8 +264,11 @@ public class Storage {
persistence.copyWallet(walletFile, outputStream);
}
public boolean delete() {
deleteBackups();
public boolean delete(boolean deleteBackups) {
if(deleteBackups) {
deleteBackups();
}
return IOUtils.secureDelete(walletFile);
}
@@ -735,18 +737,44 @@ public class Storage {
}
}
public static class CopyWalletService extends Service<Void> {
private final Wallet wallet;
private final File newWalletFile;
public CopyWalletService(Wallet wallet, File newWalletFile) {
this.wallet = wallet;
this.newWalletFile = newWalletFile;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
protected Void call() throws IOException, ExportException {
Sparrow export = new Sparrow();
try(BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(newWalletFile))) {
export.exportWallet(wallet, outputStream, null);
}
return null;
}
};
}
}
public static class DeleteWalletService extends ScheduledService<Boolean> {
private final Storage storage;
private final boolean deleteBackups;
public DeleteWalletService(Storage storage) {
public DeleteWalletService(Storage storage, boolean deleteBackups) {
this.storage = storage;
this.deleteBackups = deleteBackups;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() {
return storage.delete();
return storage.delete(deleteBackups);
}
};
}
@@ -5,7 +5,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.OutputStream;
public interface WalletExport extends ImportExport {
void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException;
void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException;
String getWalletExportDescription();
String getExportFileExtension(Wallet wallet);
boolean isWalletExportScannable();
@@ -8,4 +8,7 @@ public interface WalletImport extends FileImport {
String getWalletImportDescription();
Wallet importWallet(InputStream inputStream, String password) throws ImportException;
boolean isWalletImportScannable();
default boolean isWalletImportFileFormatAvailable() {
return true;
}
}
@@ -8,6 +8,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,7 +37,7 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public String getName() {
return "Wallet Labels";
return "Labels";
}
@Override
@@ -45,7 +46,7 @@ public class WalletLabels implements WalletImport, WalletExport {
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
List<Label> labels = new ArrayList<>();
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets();
for(Wallet exportWallet : allWallets) {
@@ -54,29 +55,32 @@ public class WalletLabels implements WalletImport, WalletExport {
for(Keystore keystore : exportWallet.getKeystores()) {
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null));
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null, null));
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin));
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
}
}
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null));
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null));
}
}
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true");
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null));
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable));
} else if(!txo.isSpent()) {
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
}
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null));
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
}
}
}
@@ -140,7 +144,15 @@ public class WalletLabels implements WalletImport, WalletExport {
continue;
}
if(label == null || label.type == null || label.ref == null || label.label == null || label.label.isEmpty()) {
if(label == null || label.type == null || label.ref == null) {
continue;
}
if(label.type == Type.output) {
if((label.label == null || label.label.isEmpty()) && label.spendable == null) {
continue;
}
} else if(label.label == null || label.label.isEmpty()) {
continue;
}
@@ -152,6 +164,7 @@ public class WalletLabels implements WalletImport, WalletExport {
Map<Wallet, List<Keystore>> changedWalletKeystores = new LinkedHashMap<>();
Map<Wallet, List<Entry>> changedWalletEntries = new LinkedHashMap<>();
Map<Wallet, List<BlockTransactionHashIndex>> changedWalletUtxoStatuses = new LinkedHashMap<>();
for(WalletForm walletForm : walletForms) {
Wallet wallet = walletForm.getWallet();
@@ -216,9 +229,21 @@ public class WalletLabels implements WalletImport, WalletExport {
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
txioEntry.getHashIndex().setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
if(label.label != null && !label.label.isEmpty()) {
reference.setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
}
if(label.type == Type.output && !reference.isSpent()) {
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
reference.setStatus(Status.FROZEN);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
reference.setStatus(null);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
}
}
}
}
}
@@ -248,6 +273,10 @@ public class WalletLabels implements WalletImport, WalletExport {
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletEntries.getKey(), walletEntries.getValue(), false));
}
for(Map.Entry<Wallet, List<BlockTransactionHashIndex>> walletUtxos : changedWalletUtxoStatuses.entrySet()) {
EventManager.get().post(new WalletUtxoStatusChangedEvent(walletUtxos.getKey(), walletUtxos.getValue()));
}
return walletForms.get(0).getWallet();
}
@@ -256,7 +285,9 @@ public class WalletLabels implements WalletImport, WalletExport {
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
hashIndexEntry.labelProperty().set(label.label);
if(label.label != null && !label.label.isEmpty()) {
hashIndexEntry.labelProperty().set(label.label);
}
}
}
}
@@ -266,6 +297,11 @@ public class WalletLabels implements WalletImport, WalletExport {
entries.add(entry);
}
private static void addChangedUtxo(Map<Wallet, List<BlockTransactionHashIndex>> changedUtxos, TransactionHashIndexEntry utxoEntry) {
List<BlockTransactionHashIndex> utxos = changedUtxos.computeIfAbsent(utxoEntry.getWallet(), w -> new ArrayList<>());
utxos.add(utxoEntry.getHashIndex());
}
@Override
public boolean isWalletImportScannable() {
return false;
@@ -281,16 +317,18 @@ public class WalletLabels implements WalletImport, WalletExport {
}
private static class Label {
public Label(Type type, String ref, String label, String origin) {
public Label(Type type, String ref, String label, String origin, String spendable) {
this.type = type;
this.ref = ref;
this.label = label;
this.origin = origin;
this.spendable = spendable;
}
Type type;
String ref;
String label;
String origin;
String spendable;
}
}
@@ -0,0 +1,179 @@
package com.sparrowwallet.sparrow.io;
import com.csvreader.CsvWriter;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.control.EntryCell;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
import org.apache.commons.lang3.time.DateUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
public class WalletTransactions implements WalletExport {
private static final long ONE_DAY = 24*60*60*1000L;
private final WalletForm walletForm;
public WalletTransactions(WalletForm walletForm) {
this.walletForm = walletForm;
}
@Override
public String getName() {
return "Transactions";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.TRANSACTIONS;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
ExchangeSource exchangeSource = Config.get().getExchangeSource();
if(Config.get().getExchangeSource() == null) {
exchangeSource = ExchangeSource.COINGECKO;
}
Currency fiatCurrency = (exchangeSource == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency());
Map<Date, Double> fiatRates = new HashMap<>();
if(fiatCurrency != null && !walletTransactionsEntry.getChildren().isEmpty()) {
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
.filter(Objects::nonNull)
.collect(Collectors.summarizingLong(Date::getTime));
fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax()));
}
BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit();
if(bitcoinUnit == null || bitcoinUnit.equals(BitcoinUnit.AUTO)) {
bitcoinUnit = walletForm.getWallet().getAutoUnit();
}
try {
CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8);
writer.write("Date");
writer.write("Label");
writer.write("Value");
writer.write("Balance");
writer.write("Fee");
if(fiatCurrency != null) {
writer.write("Value (" + fiatCurrency.getCurrencyCode() + ")");
}
writer.write("Txid");
writer.endRecord();
for(Entry entry : walletTransactionsEntry.getChildren()) {
TransactionEntry txEntry = (TransactionEntry)entry;
writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(txEntry.getBlockTransaction().getDate()));
writer.write(txEntry.getLabel());
writer.write(getCoinValue(bitcoinUnit, txEntry.getValue()));
writer.write(getCoinValue(bitcoinUnit, txEntry.getBalance()));
Long fee = txEntry.getValue() < 0 ? getFee(wallet, txEntry.getBlockTransaction()) : null;
writer.write(fee == null ? "" : getCoinValue(bitcoinUnit, fee));
if(fiatCurrency != null) {
Double fiatValue = getFiatValue(txEntry, fiatRates);
writer.write(fiatValue == null ? "" : getFiatValue(fiatValue));
}
writer.write(txEntry.getBlockTransaction().getHash().toString());
writer.endRecord();
}
if(fiatCurrency != null) {
writer.endRecord();
writer.writeComment(" Historical " + fiatCurrency.getCurrencyCode() + " values are taken from daily rates and should only be considered as approximate.");
}
writer.close();
} catch(IOException e) {
throw new ExportException("Error writing transactions CSV", e);
}
}
private Long getFee(Wallet wallet, BlockTransaction blockTransaction) {
long fee = 0L;
for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) {
if(txInput.isCoinBase()) {
return 0L;
}
BlockTransaction inputTx = wallet.getWalletTransaction(txInput.getOutpoint().getHash());
if(inputTx == null || inputTx.getTransaction().getOutputs().size() <= txInput.getOutpoint().getIndex()) {
return null;
}
TransactionOutput spentOutput = inputTx.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
fee += spentOutput.getValue();
}
for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) {
fee -= txOutput.getValue();
}
return fee;
}
private String getCoinValue(BitcoinUnit bitcoinUnit, Long value) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return BitcoinUnit.BTC.equals(bitcoinUnit) ? format.tableFormatBtcValue(value) : String.format(Locale.ENGLISH, "%d", value);
}
private String getFiatValue(Double value) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.tableFormatCurrencyValue(value);
}
private Double getFiatValue(TransactionEntry txEntry, Map<Date, Double> fiatRates) {
Double dayRate = null;
if(txEntry.getBlockTransaction().getDate() == null) {
if(AppServices.getFiatCurrencyExchangeRate() != null) {
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
dayRate = fiatRates.get(DateUtils.truncate(txEntry.getBlockTransaction().getDate(), Calendar.DAY_OF_MONTH));
}
if(dayRate != null) {
return dayRate * txEntry.getValue() / Transaction.SATOSHIS_PER_BITCOIN;
}
return null;
}
@Override
public String getWalletExportDescription() {
return "Exports a CSV file containing dates and values of the transactions in this wallet. Additionally, if a fiat currency has been configured, the fiat value on the day they were first included in the blockchain.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "csv";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
}
@@ -20,8 +20,7 @@ import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.*;
import java.util.Map;
public class CardTransport {
@@ -41,15 +40,32 @@ public class CardTransport {
throw new IllegalStateException("No reader connected");
}
CardTerminal cardTerminal = (CardTerminal)terminals.get(0);
connection = cardTerminal.connect("*");
Card connection = null;
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
try {
connection = getConnection(iter.next());
break;
} catch(CardException e) {
if(!iter.hasNext()) {
log.debug(e.getMessage());
throw e;
}
}
}
this.connection = connection;
}
private Card getConnection(CardTerminal cardTerminal) throws CardException {
Card connection = cardTerminal.connect("*");
CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
if(resp.getSW() != SW_OKAY) {
log.error("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported.");
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
}
return connection;
}
JsonObject send(String cmd, Map<String, Object> args) throws CardException {
@@ -26,7 +26,7 @@ public class Tapsigner implements KeystoreCardImport {
}
@Override
public void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException {
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
if(pin.length() < 6) {
throw new CardException("PIN too short.");
}
@@ -43,7 +43,7 @@ public class Tapsigner implements KeystoreCardImport {
throw new IllegalStateException("Card is already initialized.");
}
cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
cardApi.initialize(0, chainCode);
cardApi.initialize(0, entropy);
} finally {
if(cardApi != null) {
cardApi.disconnect();
@@ -285,7 +285,7 @@ public class DbPersistence implements Persistence {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
for(Entry entry : dirtyPersistables.labelEntries) {
if(entry instanceof TransactionEntry && ((TransactionEntry)entry).getBlockTransaction().getId() != null) {
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), entry.getLabel());
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), blockTransactionDao.truncate(entry.getLabel()));
} else if(entry instanceof NodeEntry) {
WalletNode addressNode = ((NodeEntry)entry).getNode();
if(addressNode.getId() == null) {
@@ -301,9 +301,9 @@ public class DbPersistence implements Persistence {
walletNodeDao.updateNodeAddressData(addressNode.getId(), addressNode.getAddressData());
}
walletNodeDao.updateNodeLabel(addressNode.getId(), entry.getLabel());
walletNodeDao.updateNodeLabel(addressNode.getId(), walletNodeDao.truncate(entry.getLabel()));
} else if(entry instanceof HashIndexEntry && ((HashIndexEntry)entry).getHashIndex().getId() != null) {
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), entry.getLabel());
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), walletNodeDao.truncate(entry.getLabel()));
}
}
}
@@ -583,7 +583,7 @@ public class DbPersistence implements Persistence {
@Override
public boolean isClosed() {
return dataSource.isClosed();
return dataSource == null || dataSource.isClosed();
}
@Override
@@ -21,6 +21,7 @@ public interface MixConfigDao {
default void addMixConfig(Wallet wallet) {
if(wallet.getMixConfig() != null) {
wallet.getMixConfig().setId(null);
addOrUpdate(wallet, wallet.getMixConfig());
}
}
@@ -0,0 +1,139 @@
package com.sparrowwallet.sparrow.io.satochip;
import com.sparrowwallet.drongo.Utils;
import java.io.ByteArrayOutputStream;
/**
* ISO7816-4 APDU.
*/
public class APDUCommand {
protected int cla;
protected int ins;
protected int p1;
protected int p2;
protected byte[] data;
protected boolean needsLE;
/**
* Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array.
*
* @param cla class byte
* @param ins instruction code
* @param p1 P1 parameter
* @param p2 P2 parameter
* @param data the APDU data
*/
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
this(cla, ins, p1, p2, data, false);
}
/**
* Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array.
* The LE byte, if sent, is set to 0.
*
* @param cla class byte
* @param ins instruction code
* @param p1 P1 parameter
* @param p2 P2 parameter
* @param data the APDU data
* @param needsLE whether the LE byte should be sent or not
*/
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) {
this.cla = cla & 0xff;
this.ins = ins & 0xff;
this.p1 = p1 & 0xff;
this.p2 = p2 & 0xff;
this.data = data;
this.needsLE = needsLE;
}
/**
* Serializes the APDU in order to send it to the card.
*
* @return the byte array representation of the APDU
*/
public byte[] serialize() {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(this.cla);
out.write(this.ins);
out.write(this.p1);
out.write(this.p2);
out.write(this.data.length);
out.write(this.data, 0, this.data.length);
if(this.needsLE) {
out.write(0); // Response length
}
return out.toByteArray();
}
/**
* Serializes the APDU to human readable hex string format
*
* @return the hex string representation of the APDU
*/
public String toHexString() {
byte[] raw = this.serialize();
if(raw == null) {
return "";
}
return Utils.bytesToHex(raw);
}
/**
* Returns the CLA of the APDU
*
* @return the CLA of the APDU
*/
public int getCla() {
return cla;
}
/**
* Returns the INS of the APDU
*
* @return the INS of the APDU
*/
public int getIns() {
return ins;
}
/**
* Returns the P1 of the APDU
*
* @return the P1 of the APDU
*/
public int getP1() {
return p1;
}
/**
* Returns the P2 of the APDU
*
* @return the P2 of the APDU
*/
public int getP2() {
return p2;
}
/**
* Returns the data field of the APDU
*
* @return the data field of the APDU
*/
public byte[] getData() {
return data;
}
/**
* Returns whether LE is sent or not.
*
* @return whether LE is sent or not
*/
public boolean getNeedsLE() {
return this.needsLE;
}
}
@@ -0,0 +1,119 @@
package com.sparrowwallet.sparrow.io.satochip;
import com.sparrowwallet.drongo.Utils;
/**
* ISO7816-4 APDU response.
*/
public class APDUResponse {
public static final int SW_OK = 0x9000;
public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982;
public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983;
public static final int SW_CARD_LOCKED = 0x6283;
public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88;
public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed
public static final int SW_WRONG_PIN_MASK = 0x63C0;
public static final String HEXES = "0123456789ABCDEF";
private final byte[] apdu;
private byte[] data;
private int sw;
private int sw1;
private int sw2;
/**
* Creates an APDU object by parsing the raw response from the card.
*
* @param apdu the raw response from the card.
*/
public APDUResponse(byte[] apdu) {
if(apdu.length < 2) {
throw new IllegalArgumentException("APDU response must be at least 2 bytes");
}
this.apdu = apdu;
this.parse();
}
public APDUResponse(byte[] data, byte sw1, byte sw2) {
byte[] apdu = new byte[data.length + 2];
System.arraycopy(data, 0, apdu, 0, data.length);
apdu[data.length] = sw1;
apdu[data.length + 1] = sw2;
this.apdu = apdu;
this.parse();
}
/**
* Parses the APDU response, separating the response data from SW.
*/
private void parse() {
int length = this.apdu.length;
this.sw1 = this.apdu[length - 2] & 0xff;
this.sw2 = this.apdu[length - 1] & 0xff;
this.sw = (this.sw1 << 8) | this.sw2;
this.data = new byte[length - 2];
System.arraycopy(this.apdu, 0, this.data, 0, length - 2);
}
/**
* Returns the data field of this APDU.
*
* @return the data field of this APDU
*/
public byte[] getData() {
return this.data;
}
/**
* Returns the Status Word.
*
* @return the status word
*/
public int getSw() {
return this.sw;
}
/**
* Returns the SW1 byte
*
* @return SW1
*/
public int getSw1() {
return this.sw1;
}
/**
* Returns the SW2 byte
*
* @return SW2
*/
public int getSw2() {
return this.sw2;
}
/**
* Returns the raw unparsed response.
*
* @return raw APDU data
*/
public byte[] getBytes() {
return this.apdu;
}
/**
* Serializes the APDU to human readable hex string format
*
* @return the hex string representation of the APDU
*/
public String toHexString() {
byte[] raw = this.apdu;
if(raw == null) {
return "";
}
return Utils.bytesToHex(raw);
}
}
@@ -0,0 +1,214 @@
package com.sparrowwallet.sparrow.io.satochip;
public final class Constants {
// Prevents instantiation of class
private Constants() {
}
/****************************************
* Instruction codes *
****************************************/
public static final byte CLA = (byte) 0xB0;
// Applet initialization
public static final byte INS_SETUP = (byte) 0x2A;
// Keys' use and management
public static final byte INS_IMPORT_KEY = (byte) 0x32;
public static final byte INS_RESET_KEY = (byte) 0x33;
public static final byte INS_GET_PUBLIC_FROM_PRIVATE = (byte) 0x35;
// External authentication
public static final byte INS_CREATE_PIN = (byte) 0x40; //TODO: remove?
public static final byte INS_VERIFY_PIN = (byte) 0x42;
public static final byte INS_CHANGE_PIN = (byte) 0x44;
public static final byte INS_UNBLOCK_PIN = (byte) 0x46;
public static final byte INS_LOGOUT_ALL = (byte) 0x60;
// Status information
public static final byte INS_LIST_PINS = (byte) 0x48;
public static final byte INS_GET_STATUS = (byte) 0x3C;
public static final byte INS_CARD_LABEL = (byte) 0x3D;
// HD wallet
public static final byte INS_BIP32_IMPORT_SEED = (byte) 0x6C;
public static final byte INS_BIP32_RESET_SEED = (byte) 0x77;
public static final byte INS_BIP32_GET_AUTHENTIKEY = (byte) 0x73;
public static final byte INS_BIP32_SET_AUTHENTIKEY_PUBKEY = (byte) 0x75;
public static final byte INS_BIP32_GET_EXTENDED_KEY = (byte) 0x6D;
public static final byte INS_BIP32_SET_EXTENDED_PUBKEY = (byte) 0x74;
public static final byte INS_SIGN_MESSAGE = (byte) 0x6E;
public static final byte INS_SIGN_SHORT_MESSAGE = (byte) 0x72;
public static final byte INS_SIGN_TRANSACTION = (byte) 0x6F;
public static final byte INS_PARSE_TRANSACTION = (byte) 0x71;
public static final byte INS_CRYPT_TRANSACTION_2FA = (byte) 0x76;
public static final byte INS_SET_2FA_KEY = (byte) 0x79;
public static final byte INS_RESET_2FA_KEY = (byte) 0x78;
public static final byte INS_SIGN_TRANSACTION_HASH = (byte) 0x7A;
// secure channel
public static final byte INS_INIT_SECURE_CHANNEL = (byte) 0x81;
public static final byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82;
// secure import from SeedKeeper
public static final byte INS_IMPORT_ENCRYPTED_SECRET = (byte) 0xAC;
public static final byte INS_IMPORT_TRUSTED_PUBKEY = (byte) 0xAA;
public static final byte INS_EXPORT_TRUSTED_PUBKEY = (byte) 0xAB;
public static final byte INS_EXPORT_AUTHENTIKEY = (byte) 0xAD;
// Personalization PKI support
public static final byte INS_IMPORT_PKI_CERTIFICATE = (byte) 0x92;
public static final byte INS_EXPORT_PKI_CERTIFICATE = (byte) 0x93;
public static final byte INS_SIGN_PKI_CSR = (byte) 0x94;
public static final byte INS_EXPORT_PKI_PUBKEY = (byte) 0x98;
public static final byte INS_LOCK_PKI = (byte) 0x99;
public static final byte INS_CHALLENGE_RESPONSE_PKI = (byte) 0x9A;
// reset to factory settings
public static final byte INS_RESET_TO_FACTORY = (byte) 0xFF;
/****************************************
* Error codes *
****************************************/
/**
* Entered PIN is not correct
*/
public static final short SW_PIN_FAILED = (short) 0x63C0;// includes number of tries remaining
///** DEPRECATED - Entered PIN is not correct */
//public static final short SW_AUTH_FAILED = (short) 0x9C02;
/**
* Required operation is not allowed in actual circumstances
*/
public static final short SW_OPERATION_NOT_ALLOWED = (short) 0x9C03;
/**
* Required setup is not not done
*/
public static final short SW_SETUP_NOT_DONE = (short) 0x9C04;
/**
* Required setup is already done
*/
public static final short SW_SETUP_ALREADY_DONE = (short) 0x9C07;
/**
* Required feature is not (yet) supported
*/
public static final short SW_UNSUPPORTED_FEATURE = (short) 0x9C05;
/**
* Required operation was not authorized because of a lack of privileges
*/
public static final short SW_UNAUTHORIZED = (short) 0x9C06;
/**
* Algorithm specified is not correct
*/
public static final short SW_INCORRECT_ALG = (short) 0x9C09;
/**
* There have been memory problems on the card
*/
public static final short SW_NO_MEMORY_LEFT = (short) 0x9C01;
///** DEPRECATED - Required object is missing */
//public static final short SW_OBJECT_NOT_FOUND= (short) 0x9C07;
/**
* Incorrect P1 parameter
*/
public static final short SW_INCORRECT_P1 = (short) 0x9C10;
/**
* Incorrect P2 parameter
*/
public static final short SW_INCORRECT_P2 = (short) 0x9C11;
/**
* Invalid input parameter to command
*/
public static final short SW_INVALID_PARAMETER = (short) 0x9C0F;
/**
* Eckeys initialized
*/
public static final short SW_ECKEYS_INITIALIZED_KEY = (short) 0x9C1A;
/**
* Verify operation detected an invalid signature
*/
public static final short SW_SIGNATURE_INVALID = (short) 0x9C0B;
/**
* Operation has been blocked for security reason
*/
public static final short SW_IDENTITY_BLOCKED = (short) 0x9C0C;
/**
* For debugging purposes
*/
public static final short SW_INTERNAL_ERROR = (short) 0x9CFF;
/**
* Very low probability error
*/
public static final short SW_BIP32_DERIVATION_ERROR = (short) 0x9C0E;
/**
* Incorrect initialization of method
*/
public static final short SW_INCORRECT_INITIALIZATION = (short) 0x9C13;
/**
* Bip32 seed is not initialized
*/
public static final short SW_BIP32_UNINITIALIZED_SEED = (short) 0x9C14;
/**
* Bip32 seed is already initialized (must be reset before change)
*/
public static final short SW_BIP32_INITIALIZED_SEED = (short) 0x9C17;
//** DEPRECATED - Bip32 authentikey pubkey is not initialized*/
//public static final short SW_BIP32_UNINITIALIZED_AUTHENTIKEY_PUBKEY= (short) 0x9C16;
/**
* Incorrect transaction hash
*/
public static final short SW_INCORRECT_TXHASH = (short) 0x9C15;
/**
* 2FA already initialized
*/
public static final short SW_2FA_INITIALIZED_KEY = (short) 0x9C18;
/**
* 2FA uninitialized
*/
public static final short SW_2FA_UNINITIALIZED_KEY = (short) 0x9C19;
/**
* HMAC errors
*/
public static final short SW_HMAC_UNSUPPORTED_KEYSIZE = (short) 0x9c1E;
public static final short SW_HMAC_UNSUPPORTED_MSGSIZE = (short) 0x9c1F;
/**
* Secure channel
*/
public static final short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20;
public static final short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21;
public static final short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22;
public static final short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23;
/**
* Secret data is too long for import
**/
public static final short SW_IMPORTED_DATA_TOO_LONG = (short) 0x9C32;
/**
* Wrong HMAC when importing Secret through Secure import
**/
public static final short SW_SECURE_IMPORT_WRONG_MAC = (short) 0x9C33;
/**
* Wrong Fingerprint when importing Secret through Secure import
**/
public static final short SW_SECURE_IMPORT_WRONG_FINGERPRINT = (short) 0x9C34;
/**
* No Trusted Pubkey when importing Secret through Secure import
**/
public static final short SW_SECURE_IMPORT_NO_TRUSTEDPUBKEY = (short) 0x9C35;
/**
* PKI perso error
*/
public static final short SW_PKI_ALREADY_LOCKED = (short) 0x9C40;
/**
* CARD HAS BEEN RESET TO FACTORY
*/
public static final short SW_RESET_TO_FACTORY = (short) 0xFF00;
/**
* For instructions that have been deprecated
*/
public static final short SW_INS_DEPRECATED = (short) 0x9C26;
/**
* For debugging purposes 2
*/
public static final short SW_DEBUG_FLAG = (short) 0x9FFF;
}
@@ -0,0 +1,101 @@
package com.sparrowwallet.sparrow.io.satochip;
import java.util.StringTokenizer;
/**
* Keypath object to be used with the SatochipCommandSet
*/
public class KeyPath {
private final byte[] data;
/**
* Parses a keypath into a byte array to be used with the SatochipCommandSet object.
* <p>
* A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
* <p>
* The first component should be "m", indicating the master key.
* <p>
* All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
* which indicates an hardened key.
* <p>
* An example of a valid path is "m/44'/0'/0'/0/0"
*
* @param keypath the keypath as a string
*/
public KeyPath(String keypath) {
StringTokenizer tokenizer = new StringTokenizer(keypath, "/");
String sourceOrFirstElement = tokenizer.nextToken(); // m
int componentCount = tokenizer.countTokens();
if(componentCount > 10) {
throw new IllegalArgumentException("Too many components");
}
data = new byte[4 * componentCount];
for(int i = 0; i < componentCount; i++) {
long component = parseComponent(tokenizer.nextToken());
writeComponent(component, i);
}
}
public KeyPath(byte[] data) {
this.data = data;
}
private long parseComponent(String num) {
long sign;
if(num.endsWith("'")) {
sign = 0x80000000L;
num = num.substring(0, (num.length() - 1));
} else {
sign = 0L;
}
if(num.startsWith("+") || num.startsWith("-")) {
throw new NumberFormatException("No sign allowed");
}
return (sign | Long.parseLong(num));
}
private void writeComponent(long component, int i) {
int off = (i * 4);
data[off] = (byte) ((component >> 24) & 0xff);
data[off + 1] = (byte) ((component >> 16) & 0xff);
data[off + 2] = (byte) ((component >> 8) & 0xff);
data[off + 3] = (byte) (component & 0xff);
}
/**
* The byte encoded key path.
*
* @return byte encoded key path
*/
public byte[] getData() {
return data;
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer();
sb.append('m');
for(int i = 0; i < this.data.length; i += 4) {
sb.append('/');
appendComponent(sb, i);
}
return sb.toString();
}
private void appendComponent(StringBuffer sb, int i) {
int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff);
sb.append(num);
if((this.data[i] & 0x80) == 0x80) {
sb.append('\'');
}
}
}
@@ -0,0 +1,390 @@
package com.sparrowwallet.sparrow.io.satochip;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import com.sparrowwallet.drongo.crypto.SchnorrSignature;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.CardImportPane;
import com.sparrowwallet.sparrow.io.CardApi;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import java.util.*;
import java.nio.charset.StandardCharsets;
public class SatoCardApi extends CardApi {
private static final Logger log = LoggerFactory.getLogger(SatoCardApi.class);
private final WalletModel cardType;
private final SatochipCommandSet cardProtocol;
private final String pin;
private String basePath = null;
public SatoCardApi(WalletModel cardType, String pin) throws CardException {
this.cardType = cardType;
this.cardProtocol = new SatochipCommandSet();
this.pin = pin;
}
@Override
public boolean isInitialized() throws CardException {
SatoCardStatus cardStatus = this.getStatus();
return cardStatus.isInitialized(); // setupDone && isSeeded
}
//TODO
@Override
public void initialize(int slot, byte[] seedBytes) throws CardException {
// TODO check device certificate
SatoCardStatus cardStatus = this.getStatus();
APDUResponse rapdu;
if(!cardStatus.isSetupDone()) {
byte maxPinTries = 5;
rapdu = this.cardProtocol.cardSetup(maxPinTries, pin.getBytes(StandardCharsets.UTF_8));
// check ok
}
if(!cardStatus.isSeeded()) {
// check pin
rapdu = this.cardProtocol.cardVerifyPIN(0, pin);
// todo: check PIN response
rapdu = this.cardProtocol.cardBip32ImportSeed(seedBytes);
// check ok
}
}
@Override
public WalletModel getCardType() throws CardException {
return WalletModel.SATOCHIP;
}
//TODO
@Override
public int getCurrentSlot() throws CardException {
throw new CardException("Satochip does not support slots");
}
//TODO
@Override
public ScriptType getDefaultScriptType() {
return ScriptType.P2WPKH;
}
SatoCardStatus getStatus() throws CardException {
return this.cardProtocol.getApplicationStatus();
}
@Override
public Service<Void> getAuthDelayService() throws CardException {
return null;
}
@Override
public boolean requiresBackup() throws CardException {
return false;
}
@Override
public Service<String> getBackupService() {
return null;
}
@Override
public boolean changePin(String newPin) throws CardException {
this.cardProtocol.cardChangePIN((byte) 0, this.pin, newPin);
return true;
}
void setDerivation(List<ChildNumber> derivation) throws CardException {
this.basePath = KeyDerivation.writePath(derivation);
}
@Override
public Service<Void> getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
return new CardInitializationService(seedBytes, messageProperty);
}
@Override
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
return new CardImportPane.CardImportService(new Satochip(), pin, derivation, messageProperty);
}
/*
* Satochip derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub.
* the basePath (from masterseed to xpub) is only provided in Satochip.java:getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty)
* In SatoCardApi:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field.
* currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough
*/
@Override
public Keystore getKeystore() throws CardException {
this.cardProtocol.cardVerifyPIN(0, pin);
String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath());
ExtendedKey.Header xtype = Network.get().getXpubHeader();
String xpub = this.cardProtocol.cardBip32GetXpub(keyDerivationString, xtype);
ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub);
String masterXpub = this.cardProtocol.cardBip32GetXpub("m", xtype);
ExtendedKey masterExtendedKey = ExtendedKey.fromDescriptor(masterXpub);
String masterFingerprint = Utils.bytesToHex(masterExtendedKey.getKey().getFingerprint());
KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString);
Keystore keystore = new Keystore();
keystore.setLabel(WalletModel.SATOCHIP.toDisplayString());
keystore.setKeyDerivation(keyDerivation);
keystore.setSource(KeystoreSource.HW_USB);
keystore.setExtendedPublicKey(extendedKey);
keystore.setWalletModel(WalletModel.SATOCHIP);
return keystore;
}
@Override
public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
return new SignService(wallet, psbt, messageProperty);
}
void sign(Wallet wallet, PSBT psbt) throws CardException {
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(!psbtInput.isSigned()) {
WalletNode signingNode = signingNodes.get(psbtInput);
String fullPath = null;
List<Keystore> keystores = wallet.getKeystores();
for(int i = 0; i < keystores.size(); i++) {
Keystore keystore = keystores.get(i);
WalletModel walletModel = keystore.getWalletModel();
if(walletModel == WalletModel.SATOCHIP) {
String basePath = keystore.getKeyDerivation().getDerivationPath();
String extendedPath = signingNode.getDerivationPath().substring(1);
fullPath = basePath + extendedPath;
keystore.getPubKey(signingNode);
break;
}
}
psbtInput.sign(new CardPSBTInputSigner(signingNode, fullPath));
}
}
}
@Override
public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
return new SignMessageService(message, scriptType, derivation, messageProperty);
}
String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
String fullpath = KeyDerivation.writePath(derivation);
cardProtocol.cardVerifyPIN(0, pin);
// 2FA is optionnal, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
SatoCardStatus cardStatus = this.getStatus();
if(cardStatus.needs2FA()) {
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
}
// derive the correct key in satochip
APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullpath);
// recover pubkey
SatochipParser parser = new SatochipParser();
byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu);
ECKey pubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]);
// sign msg
return pubkey.signMessage(message, scriptType, hash -> {
try {
// do the signature with satochip
byte keynbr = (byte) 0xFF;
byte[] chalresponse = null;
APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse);
byte[] sigBytes = rapdu2.getData();
return ECDSASignature.decodeFromDER(sigBytes);
} catch(Exception e) {
throw new RuntimeException(e);
}
});
}
@Override
public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
throw new UnsupportedOperationException("Satochip does not support private key export");
}
@Override
public Service<Address> getAddressService(StringProperty messageProperty) {
return null;
}
@Override
public void disconnect() {
cardProtocol.cardDisconnect();
}
public class CardInitializationService extends Service<Void> {
private final byte[] seedBytes;
private final StringProperty messageProperty;
public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
this.seedBytes = seedBytes;
this.messageProperty = messageProperty;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
if(seedBytes == null) {
throw new CardException("Failed to initialize Satochip - no seed provided");
}
initialize(0, seedBytes);
return null;
}
};
}
}
public class SignService extends Service<PSBT> {
private final Wallet wallet;
private final PSBT psbt;
private final StringProperty messageProperty;
public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
this.wallet = wallet;
this.psbt = psbt;
this.messageProperty = messageProperty;
}
@Override
protected Task<PSBT> createTask() {
return new Task<>() {
@Override
protected PSBT call() throws Exception {
sign(wallet, psbt);
return psbt;
}
};
}
}
private class CardPSBTInputSigner implements PSBTInputSigner {
private final WalletNode signingNode;
private final String fullPath;
private ECKey pubkey;
// todo: provide derivationpath instead of WalletNode??
public CardPSBTInputSigner(WalletNode signingNode, String fullPath) {
this.signingNode = signingNode;
this.fullPath = fullPath;
}
@Override
public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
try {
// 2FA is optional, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
SatoCardStatus cardStatus = getStatus();
if(cardStatus.needs2FA()) {
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
}
// verify PIN
APDUResponse rapdu0 = cardProtocol.cardVerifyPIN(0, pin);
// derive the correct key in satochip and recover pubkey
APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullPath);
SatochipParser parser = new SatochipParser();
byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu);
ECKey internalPubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]);
if(signatureType == TransactionSignature.Type.ECDSA) {
// for ECDSA, pubkey is the same as internalPubkey
pubkey = internalPubkey;
// do the signature with satochip
byte keynbr = (byte) 0xFF;
byte[] chalresponse = null;
APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse);
byte[] sigBytes = rapdu2.getData();
ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(sigBytes).toCanonicalised();
TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash);
// verify
boolean isCorrect = pubkey.verify(hash, txSig);
return txSig;
} else {
// Satochip supports schnorr signature only for version >= 0.14 !
byte[] versionBytes = cardStatus.getCardVersion();
int protocolVersion = versionBytes[0] * 256 + versionBytes[1];
if(protocolVersion < (256 * 0 + 14)) {
throw new CardException(WalletModel.SATOCHIP.toDisplayString() + " (with version below v0.14) cannot sign Taproot transactions");
}
// tweak the bip32 key according to bip341
byte keynbr = (byte) 0xFF;
byte[] tweak = null;
APDUResponse rapduTweak = cardProtocol.cardTaprootTweakPrivkey(keynbr, tweak);
byte[] tweakedPubkeyBytes = new byte[65];
System.arraycopy(rapduTweak.getData(), 2, tweakedPubkeyBytes, 0, 65);
pubkey = ECKey.fromPublicOnly(tweakedPubkeyBytes);
byte[] chalresponse = null;
APDUResponse rapdu2 = cardProtocol.cardSignSchnorrHash(keynbr, hash.getBytes(), chalresponse);
byte[] sigBytes = rapdu2.getData();
SchnorrSignature schnorrSig = SchnorrSignature.decode(sigBytes);
TransactionSignature txSig = new TransactionSignature(schnorrSig, sigHash);
// verify sig with outputPubkey...
boolean isCorrect2 = pubkey.verify(hash, txSig);
return txSig; //new TransactionSignature(schnorrSig, sigHash);
}
} catch(Exception e) {
throw new RuntimeException(e);
}
}
@Override
public ECKey getPubKey() {
return pubkey;
}
}
public class SignMessageService extends Service<String> {
private final String message;
private final ScriptType scriptType;
private final List<ChildNumber> derivation;
private final StringProperty messageProperty;
public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
this.message = message;
this.scriptType = scriptType;
this.derivation = derivation;
this.messageProperty = messageProperty;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
@Override
protected String call() throws Exception {
return signMessage(message, scriptType, derivation);
}
};
}
}
}
@@ -0,0 +1,119 @@
package com.sparrowwallet.sparrow.io.satochip;
/**
* Parses the result of a GET STATUS command retrieving application status.
*/
public class SatoCardStatus {
private boolean setup_done = false;
private boolean is_seeded = false;
private boolean needs_secure_channel = false;
private boolean needs_2FA = false;
private byte protocol_major_version = (byte) 0;
private byte protocol_minor_version = (byte) 0;
private byte applet_major_version = (byte) 0;
private byte applet_minor_version = (byte) 0;
private byte PIN0_remaining_tries = (byte) 0;
private byte PUK0_remaining_tries = (byte) 0;
private byte PIN1_remaining_tries = (byte) 0;
private byte PUK1_remaining_tries = (byte) 0;
private int protocol_version = 0; //(d["protocol_major_version"]<<8)+d["protocol_minor_version"]
/**
* Constructor from TLV data
*
* @param rapdu the TLV data
* @throws IllegalArgumentException if the TLV does not follow the expected format
*/
public SatoCardStatus(APDUResponse rapdu) {
int sw = rapdu.getSw();
if(sw == 0x9000) {
byte[] data = rapdu.getData();
protocol_major_version = data[0];
protocol_minor_version = data[1];
applet_major_version = data[2];
applet_minor_version = data[3];
protocol_version = (protocol_major_version << 8) + protocol_minor_version;
if(data.length >= 8) {
PIN0_remaining_tries = data[4];
PUK0_remaining_tries = data[5];
PIN1_remaining_tries = data[6];
PUK1_remaining_tries = data[7];
needs_2FA = false; //default value
}
if(data.length >= 9) {
needs_2FA = data[8] != 0X00;
}
if(data.length >= 10) {
is_seeded = data[9] != 0X00;
}
if(data.length >= 11) {
setup_done = data[10] != 0X00;
} else {
setup_done = true;
}
if(data.length >= 12) {
needs_secure_channel = data[11] != 0X00;
} else {
needs_secure_channel = false;
needs_2FA = false; //default value
}
} else if(sw == 0x9c04) {
setup_done = false;
is_seeded = false;
needs_secure_channel = false;
} else {
throw new IllegalArgumentException("Invalid getStatus data");
}
}
// getters
public boolean isSeeded() {
return is_seeded;
}
public boolean isSetupDone() {
return setup_done;
}
public boolean isInitialized() {
return (setup_done && is_seeded);
}
public boolean needsSecureChannel() {
return needs_secure_channel;
}
public boolean needs2FA() {
return needs_2FA;
}
public byte getPin0RemainingCounter() {
return PIN0_remaining_tries;
}
public byte[] getCardVersion() {
byte[] versionBytes = new byte[4];
versionBytes[0] = protocol_major_version;
versionBytes[1] = protocol_minor_version;
versionBytes[2] = applet_major_version;
versionBytes[3] = applet_minor_version;
return versionBytes;
}
@Override
public String toString() {
return "setup_done: " + setup_done + "\n" +
"is_seeded: " + is_seeded + "\n" +
"needs_2FA: " + needs_2FA + "\n" +
"needs_secure_channel: " + needs_secure_channel + "\n" +
"protocol_major_version: " + protocol_major_version + "\n" +
"protocol_minor_version: " + protocol_minor_version + "\n" +
"applet_major_version: " + applet_major_version + "\n" +
"applet_minor_version: " + applet_minor_version;
}
}
@@ -0,0 +1,63 @@
package com.sparrowwallet.sparrow.io.satochip;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import javax.smartcardio.CardChannel;
import java.util.List;
import java.util.*;
public class SatoCardTransport {
private static final Logger log = LoggerFactory.getLogger(SatoCardTransport.class);
private final Card connection;
SatoCardTransport(byte[] appletAid) throws CardException {
TerminalFactory tf = TerminalFactory.getDefault();
List<CardTerminal> terminals = tf.terminals().list();
if(terminals.isEmpty()) {
throw new IllegalStateException("No reader connected");
}
Card connection = null;
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
try {
connection = getConnection(iter.next(), appletAid);
break;
} catch(CardException e) {
if(!iter.hasNext()) {
log.info(e.getMessage());
throw e;
}
}
}
this.connection = connection;
}
private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException {
Card connection = cardTerminal.connect("*");
CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid));
if(resp.getSW() != APDUResponse.SW_OK) {
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
}
return connection;
}
APDUResponse send(APDUCommand capdu) throws CardException {
javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel();
CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData());
ResponseAPDU resp = cardChannel.transmit(cmd);
return new APDUResponse(resp.getBytes());
}
void disconnect() throws CardException {
connection.disconnect(true);
}
}
@@ -0,0 +1,95 @@
package com.sparrowwallet.sparrow.io.satochip;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import com.sparrowwallet.sparrow.io.ImportException;
import javafx.beans.property.StringProperty;
import javax.smartcardio.CardException;
import java.util.List;
public class Satochip implements KeystoreCardImport {
@Override
public boolean isInitialized() throws CardException {
SatoCardApi cardApi = null;
try {
cardApi = new SatoCardApi(WalletModel.SATOCHIP, null);
return cardApi.isInitialized();
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
if(pin.length() < 4) {
throw new CardException("PIN too short.");
}
if(pin.length() > 16) {
throw new CardException("PIN too long.");
}
SatoCardApi cardApi = null;
try {
cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin);
SatoCardStatus cardStatus = cardApi.getStatus();
if(cardStatus.isInitialized()) {
throw new IllegalStateException("Card is already initialized.");
}
cardApi.initialize(0, entropy);
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
if(pin.length() < 4) {
throw new ImportException("PIN too short.");
}
if(pin.length() > 16) {
throw new ImportException("PIN too long.");
}
SatoCardApi cardApi = null;
try {
cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin);
SatoCardStatus cardStatus = cardApi.getStatus();
if(!cardStatus.isInitialized()) {
throw new IllegalStateException("Card is not initialized.");
}
cardApi.setDerivation(derivation);
return cardApi.getKeystore();
} catch(Exception e) {
throw new ImportException(e);
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import the keystore from your Satochip by inserting or placing it on the card reader.";
}
@Override
public String getName() {
return "Satochip";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SATOCHIP;
}
}

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