Compare commits

...
127 Commits
Author SHA1 Message Date
Craig Raw b50cf77f3b rename xPub to xpub 2020-10-07 13:11:46 +02:00
Craig Raw 5e8148e5b9 v0.9.5 2020-10-07 12:37:10 +02:00
Craig Raw fa5ac7917b add cobo vault singlesig and multisig file import and export 2020-10-07 12:31:58 +02:00
Craig Raw eda712e269 add comment to transaction entry equals 2020-10-06 15:10:24 +02:00
Craig Raw 3c6adafc15 tweak help copy and ui 2020-10-06 14:50:25 +02:00
Craig Raw 639e145672 use tPubs instead of xPubs on labels in testnet 2020-10-06 12:39:30 +02:00
Craig Raw eb8d66bf25 add utility buttons to import and export xpubs with qr codes, show slip132 versions 2020-10-05 17:32:30 +02:00
Craig Raw 3a885b3a28 improve open wallet error dialog message 2020-10-05 12:33:05 +02:00
Craig Raw 7e93b6b198 fix byteword list as per https://github.com/BlockchainCommons/bc-bytewords/commit/9f7ec3c73c39c9638c581beb056ed03c5a3be9ff 2020-10-05 11:39:28 +02:00
Craig Raw f949bcc1cf attempt to import wallet file if open fails 2020-10-05 10:36:27 +02:00
Craig Raw 9e5937d2fe check history on all created tx script hashes when one is updated 2020-10-02 15:18:12 +02:00
Craig Raw 82a8f08440 upgrade to javafx 15, upgrade gradle plugins, ensure reproducible builds 2020-10-01 14:09:36 +02:00
Craig Raw c806959199 add doc for updating submodules 2020-10-01 12:04:41 +02:00
Craig Raw f7fb62fcea fix on windows 2020-10-01 12:00:38 +02:00
Craig Raw ea2783e51b testnet related changes and network improvements and logging 2020-10-01 09:11:02 +02:00
Craig Raw ff7a35e68b select alternative network, support testnet on hwi, other fixes 2020-09-29 15:45:22 +02:00
Craig Raw c68cda5b2f handle support for alternative (non-mainnet) networks 2020-09-28 14:32:02 +02:00
Craig Raw 2cb667a671 shorten tooltip delay, set tooltip font size equal 2020-09-26 12:43:51 +02:00
Craig Raw eb09076604 exclude utxos from transaction diagram 2020-09-26 12:30:33 +02:00
Craig Raw 32bc9f6a99 dont refresh invalid wallets 2020-09-25 17:56:57 +02:00
Craig Raw dc8a8dd857 copy non-xpub value 2020-09-25 17:48:24 +02:00
Craig Raw d5a26fdf5d followup to prev commit to handle button disable 2020-09-25 15:37:11 +02:00
Craig Raw e48e40da0d retrieve min fee rate, adjust block target rates and dont create txes with insufficient fee rate 2020-09-25 15:22:52 +02:00
Craig Raw e878c4ea78 hide validation error on clear send 2020-09-25 14:07:16 +02:00
Craig Raw ead8fa2207 clear send details on mempool tx, edit wallet output descriptor 2020-09-25 13:43:55 +02:00
Craig Raw 9a395f5dd1 v0.9.4 2020-09-21 10:00:35 +02:00
Craig Raw a99114cc6f dynamic blockchain form updating, minor tweaks 2020-09-21 09:06:01 +02:00
Craig Raw ea36c63ebb version check: test against predefined address 2020-09-20 15:09:54 +02:00
Craig Raw 77d3c848f9 add dark theme 2020-09-20 15:04:53 +02:00
Craig Raw f22312e04f add software update check 2020-09-18 15:48:05 +02:00
Craig Raw b9db4421df import transaction labels from electrum 2020-09-18 12:53:05 +02:00
Craig Raw b7aa73e99f show wallet loading progress 2020-09-18 10:50:41 +02:00
Craig Raw 4bad46c9e3 optimize electrum server queries 2020-09-17 15:10:56 +02:00
Craig Raw 084a3c76af start with simple RPC for maximum compatibility 2020-09-15 12:07:37 +02:00
Craig Raw 2e0ca1b4fa add built in tor 2020-09-14 10:51:38 +02:00
Craig Raw 4bc446724d fix keepkey hww 2020-09-10 11:40:21 +02:00
Craig Raw 0c56f5a9a1 add change password functionality 2020-09-09 16:15:33 +02:00
Craig Raw 38b27bb091 configurable wallet gap limit 2020-09-09 14:56:50 +02:00
Craig Raw b8c3bf1bea improve wallet descriptor and add output descriptor retrieval 2020-09-09 11:18:13 +02:00
Craig Raw e65f1ef3cc export specter with checksum 2020-09-08 12:42:15 +02:00
Craig Raw f25d2618b6 descriptor change multi to sortedmulti 2020-09-08 08:31:42 +02:00
Craig Raw 0adf94bf62 initial specter wallet import and export functionality 2020-09-07 17:23:12 +02:00
Craig Raw 7d7c5e5064 Support replacing existing keystore with watch only keystore 2020-09-04 13:00:40 +02:00
Craig Raw 10e4d8843d change bip39 confirmation wording 2020-09-03 20:54:46 +02:00
Craig Raw 6e10784e49 support usb message signing 2020-09-03 20:46:44 +02:00
Craig Raw 885efd7985 support bip 137 for message signing, but handle electrum approach as well when verifying 2020-09-03 19:26:54 +02:00
Craig Raw ec9534f8ae version 0.9.3 release 2020-09-03 13:41:12 +02:00
Craig Raw 6c90a7e0ba add message signing/verifying functionality 2020-09-03 13:36:43 +02:00
Craig Raw 9dd52f120f add ur toString 2020-09-03 09:38:16 +02:00
Craig Raw 4cdffec060 fix fiat amount update on transaction update 2020-09-01 13:13:38 +02:00
Craig Raw ed9bc882b7 minor ui fix, safe unsubscribe 2020-09-01 11:33:18 +02:00
Craig Raw 5054ab9564 disable sign button if signing wallet is closed 2020-09-01 09:50:44 +02:00
Craig Raw 7751f940f2 display output send type better, handle consolidation sends 2020-09-01 08:58:47 +02:00
Craig Raw 3f68ca43c2 dont show uninitialized devices (trezor t) 2020-09-01 08:04:58 +02:00
Craig Raw 68239e9472 improve device detection and display 2020-08-31 15:55:36 +02:00
Craig Raw 10edf31e13 dont open the same wallet twice 2020-08-31 11:47:06 +02:00
Craig Raw 61c3c4a3ac use minimum fee rate found for higher block targets if present 2020-08-31 11:28:03 +02:00
Craig Raw 5d6bb5e4f9 show better descriptions on outputs based on signing wallet 2020-08-31 11:22:15 +02:00
Craig Raw b808222faa deduplicate signed keystores on signature 2020-08-31 10:14:48 +02:00
Craig Raw 4d7d51f3f8 dont show status server banner (but show height in tooltip) 2020-08-30 13:47:31 +02:00
Craig Raw 546076ea48 avoid displaying any server response in error dialogs 2020-08-30 13:29:42 +02:00
Craig Raw 2bebb0925e wallet save fixes 2020-08-30 11:24:49 +02:00
Craig Raw 47855228d3 wallet load order, dont clear history on trivial changes 2020-08-30 11:15:45 +02:00
Craig Raw a6e41e3f9d fix icon on app installer 2020-08-29 20:44:50 +02:00
Craig Raw 04722204a6 set app name on X11 2020-08-29 20:06:50 +02:00
Craig Raw 452afe4731 tweak naming 2020-08-29 18:04:27 +02:00
Craig Raw af36d7b5a5 update readme for initial release 2020-08-29 17:56:09 +02:00
Craig Raw 7e225e794f fix commit 2020-08-28 19:41:32 +02:00
Craig Raw ffce18919f Merge branch 'master' of github.com:sparrowwallet/sparrow into master 2020-08-28 19:39:34 +02:00
Craig Raw ddcabf721e dont poll when signing 2020-08-28 19:38:55 +02:00
Craig Raw 7eb4b9c61d linux build fixes 2020-08-28 16:02:08 +02:00
Craig Raw 6dca03f8b4 build tweak #2 2020-08-28 15:34:35 +02:00
Craig Raw 135c1d1719 build tweak 2020-08-28 15:29:25 +02:00
Craig Raw c9a4fb4ec4 license 2020-08-28 15:20:31 +02:00
Craig Raw 06957b12e6 build ubuntu 2020-08-28 14:42:38 +02:00
Craig Raw d1fbe94d90 welcome page tweak 2020-08-28 14:41:26 +02:00
Craig Raw 1fea119173 shorten button width 2020-08-28 13:31:27 +02:00
Craig Raw 1ac264600a set linux path explicitly 2020-08-28 12:17:47 +02:00
Craig Raw 00db59c44f fix pixel stretching issue 2020-08-28 09:49:26 +02:00
Craig Raw 2c27f702bf add import 2020-08-27 17:41:17 +02:00
Craig Raw 3c2dfed96d locale and other windows related fixes 2020-08-27 17:16:20 +02:00
Craig Raw eb23cc8e61 osx only run options 2020-08-27 15:23:42 +02:00
Craig Raw fa0c74f06b set monospace font explicitly, add stage icons 2020-08-27 13:11:23 +02:00
Craig Raw ba017b2fcb windows fixes 2020-08-25 19:30:19 +02:00
Craig Raw 0a094332d2 fix folder name 2020-08-25 17:24:22 +02:00
Craig Raw 2cca6049be create installers, fix about border 2020-08-25 17:18:45 +02:00
Craig Raw 5be1f066df use matrix 2020-08-25 16:20:45 +02:00
Craig Raw d519c033ac bump version, cross platform builds 2020-08-25 16:10:56 +02:00
Craig Raw f8d15f73f6 platform specific changes 2020-08-25 15:31:04 +02:00
Craig Raw 350bddac84 java versioning 2020-08-25 12:17:33 +02:00
Craig Raw d1326cb621 update java point release 2020-08-25 09:17:28 +02:00
Craig Raw ae0e5894ca fix submodule url 2020-08-25 07:50:02 +02:00
Craig Raw 1a931c72bd checkout submodules 2020-08-24 17:46:28 +02:00
Craig Raw 92db6d9396 remove newline 2020-08-24 15:54:54 +02:00
Craig Raw 5fd59cc68a fix event name #2 2020-08-24 15:40:44 +02:00
Craig Raw ea9a310b22 fix event name 2020-08-24 15:39:31 +02:00
Craig Raw ee22d4a81d osx github action initial commit 2020-08-24 15:37:35 +02:00
Craig Raw c2fb6bcfee build, versioning and cross-platform about dlg 2020-08-24 14:52:05 +02:00
Craig Raw 8cde670efd log all errors to error log 2020-08-24 12:54:11 +02:00
Craig Raw f95cb67912 add coldcard single sig wallet import 2020-08-24 12:05:32 +02:00
Craig Raw 750f6483cb show alternative xpub values as tooltips 2020-08-24 10:12:16 +02:00
Craig Raw 53a590140d spend utxo improvements 2020-08-23 15:47:43 +02:00
Craig Raw 189ac75467 set null date on unconfirmed txes, dont let external utxos show as spendable 2020-08-22 17:20:43 +02:00
Craig Raw fa082e892f about dialog, older mac hwi build 2020-08-21 20:58:11 +02:00
Craig Raw c814b8e350 various fixes, better non-electrumx support 2020-08-21 17:16:14 +02:00
Craig Raw 2b48b4cd6e better handling of server connection editing 2020-08-21 10:48:41 +02:00
Craig Raw d49f85fe5b more import fixes 2020-08-21 10:08:43 +02:00
Craig Raw af525797ff improvements to importing and exporting 2020-08-21 09:59:11 +02:00
Craig Raw e9e10de266 export file naming with correct extensions 2020-08-20 18:20:42 +02:00
Craig Raw ab5fdef919 display full derivation path where possible 2020-08-20 17:59:58 +02:00
Craig Raw f723d20c9a disable absolute locktime if sequence disabled 2020-08-20 17:40:56 +02:00
Craig Raw 03c488cf4d add warnings to block time, set current height, close once 2020-08-20 17:30:39 +02:00
Craig Raw ca64a1d307 scan qr to bitcoin uri 2020-08-20 15:26:59 +02:00
Craig Raw 4c8f7bb711 clear fiat amounts 2020-08-20 12:30:21 +02:00
Craig Raw 77dd8ca5d7 unregister closed tab and dialog subscribers, fix connection error reset behaviour 2020-08-20 11:25:34 +02:00
Craig Raw d5aba35184 make controller and tab data share data sources 2020-08-19 15:44:40 +02:00
Craig Raw 30e2ab5e2a fill tx labels on send 2020-08-19 08:40:06 +02:00
Craig Raw e93fcf7fbd show fiat fee amount 2020-08-18 12:54:43 +02:00
Craig Raw 198672a06e further keystore improvements 2020-08-18 11:50:27 +02:00
Craig Raw 133d771c09 keystore buttons label tooltips 2020-08-18 11:02:45 +02:00
Craig Raw 9a80065605 connection toggle tooltip 2020-08-18 10:55:07 +02:00
Craig Raw ccf4e98968 ellipsis and incomplete wallet background save fixes 2020-08-18 10:47:32 +02:00
Craig Raw df15129f64 bip39 word list improvements #2 2020-08-18 10:20:56 +02:00
Craig Raw e878648587 bip39 word list usability improvements 2020-08-17 19:42:30 +02:00
Craig Raw 75bf63d936 fix wallet settings update bugs 2020-08-17 17:25:33 +02:00
Craig Raw 304bdd5c48 remove sys err exception logging 2020-08-17 17:00:48 +02:00
Craig Raw e1934c99bc upgrade controlsfx, fix notifications bug 2020-08-17 16:48:31 +02:00
192 changed files with 5191 additions and 888 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Package
on: workflow_dispatch
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v2
with:
submodules: true
- name: Set up JDK 14.0.2
uses: actions/setup-java@v1
with:
java-version: 14.0.2
- name: Show Build Versions
run: ./gradlew -v
- name: Cache Gradle packages
uses: actions/cache@v2
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
restore-keys: ${{ runner.os }}-gradle
- name: Build with Gradle
run: ./gradlew jpackage
- name: Upload Artifacts
uses: actions/upload-artifact@v2
with:
name: Sparrow Build - ${{ runner.os }}
path: build/jpackage/*
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "drongo"]
path = drongo
url = git@github.com:craigraw/drongo.git
url = git@github.com:sparrowwallet/drongo.git
+174 -16
View File
@@ -1,19 +1,177 @@
Copyright (c) 2020 Craig Raw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+69 -7
View File
@@ -1,11 +1,73 @@
# Sparrow
Bitcoin Wallet & Transaction Editor
# Sparrow Bitcoin Wallet
To clone this project, use `git clone --recursive git@github.com:craigraw/sparrow.git`
Sparrow is a modern desktop Bitcoin wallet application supporting most hardware wallets and built on common standards such as PSBT, with an emphasis on transparency and usability.
## Various ways to hex dump a file without spaces:
`xxd -p file | tr -d '\n'`
More information (and release binaries) can be found at https://sparrowwallet.com. Release binaries are also available directly from [Github](https://github.com/sparrowwallet/sparrow/releases).
`hexdump -ve '1/1 "%.2x"'`
![Sparrow Wallet](https://sparrowwallet.com/assets/images/control-your-sends.png)
`od -t x1 -An file | tr -d '\n '`
## Building
To clone this project, use
`git clone --recursive git@github.com:sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 14 to be installed. The release packages can be built using
`./gradlew jpackage`
When updating to the latest HEAD
`git pull --recurse-submodules`
All jar files created are reproducible builds.
## Running
If you prefer to run Sparrow directly from source, it can be launched with
`./sparrow`
Java 14 must be installed.
## Configuration
Sparrow has a number of command line options, for example to change it's home folder or use testnet:
```
./sparrow -h
Usage: sparrow [options]
Options:
--dir, -d
Path to Sparrow home folder
--help, -h
Show usage
--network, -n
Network to use
Possible Values: [mainnet, testnet, regtest]
```
As a fallback, the network (mainnet, testnet or regtest) can also be set using an environment variable `SPARROW_NETWORK`. For example:
`export SPARROW_NETWORK=testnet`
Note that if you are connecting to an Electrum server when using testnet, that server will need to running on testnet configuration as well.
When not explicitly configured using the command line argument above, Sparrow stores it's mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
Platform | Location
-------- | --------
OSX | ~/.sparrow
Linux | ~/.sparrow
Windows | %APPDATA%/Sparrow
Testnet and regtest configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
## Reporting Issues
Please use the [Issues](https://github.com/sparrowwallet/sparrow/issues) tab above to report an issue. If possible, look in the sparrow.log file in the configuration directory for information helpful in debugging.
## License
Sparrow is licensed under the Apache 2 software licence.
+56 -20
View File
@@ -1,23 +1,34 @@
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.0.8'
id 'org.openjfx.javafxplugin' version '0.0.9'
id 'org.kordamp.gradle.jdeps' version '0.9.0'
id 'org.beryx.jlink' version '2.17.4'
id 'org.beryx.jlink' version '2.22.0'
}
group 'com.sparrowwallet'
version '1.0-SNAPSHOT'
def sparrowVersion = '0.9.5'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
osName = "osx"
}
sourceCompatibility = 1.9
group "com.sparrowwallet"
version "${sparrowVersion}"
repositories {
mavenCentral()
maven { url 'https://oss.sonatype.org/content/groups/public' }
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
maven { url 'https://jitpack.io' }
}
tasks.withType(AbstractArchiveTask) {
preserveFileTimestamps = false
reproducibleFileOrder = true
}
javafx {
version = "14"
version = "15"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -44,8 +55,9 @@ dependencies {
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8")
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.0.1' ) {
implementation('org.controlsfx:controlsfx:11.0.2' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics'
exclude group: 'org.openjfx', module: 'javafx-controls'
@@ -62,13 +74,22 @@ mainClassName = 'com.sparrowwallet.sparrow/com.sparrowwallet.sparrow.MainApp'
compileJava {
options.with {
fork = true
compilerArgs.addAll(["--add-exports", "org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow"])
compilerArgs.addAll(["--add-exports", "org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-exports", "javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-exports", "javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow"])
}
}
processResources {
doLast {
delete fileTree("$buildDir/resources/main/native").matching {
exclude "${osName}/**"
}
}
}
run {
applicationDefaultJvmArgs = ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
applicationDefaultJvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
@@ -81,7 +102,14 @@ run {
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
}
jlink {
@@ -93,6 +121,7 @@ jlink {
requires 'javafx.base'
requires 'com.fasterxml.jackson.databind'
requires 'jdk.crypto.cryptoki'
requires 'java.management'
}
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png']
@@ -111,25 +140,32 @@ jlink {
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.merged.module",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.menu=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop"]
}
addExtraDependencies("javafx")
jpackage {
imageName = "Sparrow"
installerName = "Sparrow"
appVersion = "0.6"
skipInstaller = true
appVersion = "${sparrowVersion}"
skipInstaller = os.macOsX
imageOptions = []
installerOptions = [
'--file-associations', 'src/main/resources/associations.properties',
]
if (org.gradle.internal.os.OperatingSystem.current().windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu']
installerOptions = ['--file-associations', 'src/main/deploy/associations.properties', '--license-file', 'LICENSE']
if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
installerType = "exe"
}
if (org.gradle.internal.os.OperatingSystem.current().macOsX) {
if(os.linux) {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-rpm-license-type', 'ASL 2.0']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
}
if(os.macOsX) {
installerOptions += ['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)']
imageOptions += ['--icon', 'src/main/deploy/package/macosx/sparrow.icns', '--resource-dir', 'src/main/deploy/package/macosx/']
imageOptions += ['--icon', 'src/main/deploy/package/osx/sparrow.icns', '--resource-dir', 'src/main/deploy/package/osx/']
installerType = "dmg"
}
}
+1 -1
Submodule drongo updated: 6a2af38b8a...4b6c477030
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env sh
args="$*"
args="${args%"${args##*[![:space:]]}"}"
if [ -n "$args" ]
then
./gradlew run --args="$args"
else
./gradlew run
fi
+8
View File
@@ -0,0 +1,8 @@
@echo off
set ARGS=%*
if "%ARGS%" == "" (
gradlew.bat run
) else (
gradlew.bat run --args="%ARGS%"
)
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.6</string>
<string>0.9.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,25 @@
package com.sparrowwallet.sparrow;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.stage.Stage;
public class AboutController {
private Stage stage;
@FXML
private Label title;
public void initializeView() {
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION);
}
public void setStage(Stage stage) {
this.stage = stage;
}
public void close(ActionEvent event) {
stage.close();
}
}
@@ -4,6 +4,7 @@ import com.google.common.base.Charsets;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteSource;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
@@ -19,8 +20,10 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.VersionCheckService;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.transaction.TransactionController;
import com.sparrowwallet.sparrow.transaction.TransactionData;
import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.WalletController;
import com.sparrowwallet.sparrow.wallet.WalletForm;
@@ -37,14 +40,18 @@ import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Dragboard;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.Window;
import javafx.util.Duration;
import org.controlsfx.control.Notifications;
@@ -65,8 +72,8 @@ public class AppController implements Initializable {
private static final int SERVER_PING_PERIOD = 10 * 1000;
private static final int ENUMERATE_HW_PERIOD = 30 * 1000;
private static final int RATES_PERIOD = 5 * 60 * 1000;
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
@@ -83,12 +90,18 @@ public class AppController implements Initializable {
@FXML
private Menu fileMenu;
@FXML
private Menu helpMenu;
@FXML
private MenuItem openTransactionIdItem;
@FXML
private ToggleGroup bitcoinUnit;
@FXML
private ToggleGroup theme;
@FXML
private CheckMenuItem showTxHex;
@@ -117,12 +130,16 @@ public class AppController implements Initializable {
private Hwi.ScheduledEnumerateService deviceEnumerateService;
private VersionCheckService versionCheckService;
private static Integer currentBlockHeight;
public static boolean showTxHexProperty;
private static Map<Integer, Double> targetBlockFeeRates;
private static Double minimumRelayFeeRate;
private static CurrencyRate fiatCurrencyExchangeRate;
private static List<Device> devices;
@@ -178,7 +195,8 @@ public class AppController implements Initializable {
}
});
tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER);
//Draggle tabs introduce unwanted movement when selecting between them
//tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER);
tabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
if(c.next() && (c.wasAdded() || c.wasRemoved())) {
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
@@ -187,6 +205,18 @@ public class AppController implements Initializable {
EventManager.get().post(new OpenWalletsEvent(getOpenWallets()));
}
List<WalletTabData> closedWalletTabs = c.getRemoved().stream().map(tab -> (TabData)tab.getUserData())
.filter(tabData -> tabData.getType() == TabData.TabType.WALLET).map(tabData -> (WalletTabData)tabData).collect(Collectors.toList());
if(!closedWalletTabs.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedWalletTabs));
}
List<TransactionTabData> closedTransactionTabs = c.getRemoved().stream().map(tab -> (TabData)tab.getUserData())
.filter(tabData -> tabData.getType() == TabData.TabType.TRANSACTION).map(tabData -> (TransactionTabData)tabData).collect(Collectors.toList());
if(!closedTransactionTabs.isEmpty()) {
EventManager.get().post(new TransactionTabsClosedEvent(closedTransactionTabs));
}
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage)tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
@@ -200,8 +230,18 @@ public class AppController implements Initializable {
Config.get().setBitcoinUnit(unit);
}
final BitcoinUnit selectedUnit = unit;
Optional<Toggle> selectedToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
Theme configTheme = Config.get().getTheme();
if(configTheme == null) {
configTheme = Theme.LIGHT;
Config.get().setTheme(Theme.LIGHT);
}
final Theme selectedTheme = configTheme;
Optional<Toggle> selectedThemeToggle = theme.getToggles().stream().filter(toggle -> selectedTheme.equals(toggle.getUserData())).findFirst();
selectedThemeToggle.ifPresent(toggle -> theme.selectToggle(toggle));
setTheme(null);
showTxHex.setSelected(true);
showTxHexProperty = true;
@@ -226,14 +266,26 @@ public class AppController implements Initializable {
if(!ratesService.isRunning() && ratesService.getExchangeSource() != ExchangeSource.NONE) {
ratesService.start();
}
if(versionCheckService.getState() == Worker.State.CANCELLED) {
versionCheckService.reset();
}
if(!versionCheckService.isRunning() && Config.get().isCheckNewVersions()) {
versionCheckService.start();
}
} else {
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
}
}
});
onlineProperty.bindBidirectional(serverToggle.selectedProperty());
onlineProperty().addListener((observable, oldValue, newValue) -> {
Platform.runLater(this::setServerToggleTooltip);
});
Config config = Config.get();
connectionService = createConnectionService();
@@ -244,10 +296,15 @@ public class AppController implements Initializable {
ExchangeSource source = config.getExchangeSource() != null ? config.getExchangeSource() : DEFAULT_EXCHANGE_SOURCE;
Currency currency = config.getFiatCurrency() != null ? config.getFiatCurrency() : DEFAULT_FIAT_CURRENCY;
ratesService = createRatesService(source, currency);
if (config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) {
if(config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) {
ratesService.start();
}
versionCheckService = createVersionCheckService();
if(config.getMode() == Mode.ONLINE && config.isCheckNewVersions()) {
versionCheckService.start();
}
openTransactionIdItem.disableProperty().bind(onlineProperty.not());
}
@@ -256,6 +313,13 @@ public class AppController implements Initializable {
connectionService.setPeriod(new Duration(SERVER_PING_PERIOD));
connectionService.setRestartOnFailure(true);
EventManager.get().register(connectionService);
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
if(connectionService.isRunning()) {
EventManager.get().post(new StatusEvent(newValue));
}
});
connectionService.setOnSucceeded(successEvent -> {
changeMode = false;
onlineProperty.setValue(true);
@@ -266,10 +330,14 @@ public class AppController implements Initializable {
}
});
connectionService.setOnFailed(failEvent -> {
//Close connection here to create a new transport next time we try
connectionService.resetConnection();
changeMode = false;
onlineProperty.setValue(false);
changeMode = true;
log.debug("Connection failed", failEvent.getSource().getException());
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException()));
});
@@ -288,6 +356,22 @@ public class AppController implements Initializable {
return ratesService;
}
private VersionCheckService createVersionCheckService() {
VersionCheckService versionCheckService = new VersionCheckService();
versionCheckService.setDelay(Duration.seconds(10));
versionCheckService.setPeriod(Duration.hours(VERSION_CHECK_PERIOD_HOURS));
versionCheckService.setRestartOnFailure(true);
versionCheckService.setOnSucceeded(successEvent -> {
VersionUpdatedEvent event = versionCheckService.getValue();
if(event != null) {
EventManager.get().post(event);
}
});
return versionCheckService;
}
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() {
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null);
enumerateService.setPeriod(new Duration(ENUMERATE_HW_PERIOD));
@@ -312,20 +396,49 @@ public class AppController implements Initializable {
}
private void setOsxApplicationMenu() {
if(org.controlsfx.tools.Platform.getCurrent().getPlatformId().toLowerCase().equals("mac")) {
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX) {
MenuToolkit tk = MenuToolkit.toolkit();
MenuItem preferences = new MenuItem("Preferences...");
preferences.setOnAction(this::openPreferences);
Menu defaultApplicationMenu = new Menu("Apple", null, tk.createAboutMenuItem(MainApp.APP_NAME, null), new SeparatorMenuItem(),
Menu defaultApplicationMenu = new Menu("Apple", null, tk.createAboutMenuItem(MainApp.APP_NAME, getAboutStage()), new SeparatorMenuItem(),
preferences, new SeparatorMenuItem(),
tk.createHideMenuItem(MainApp.APP_NAME), tk.createHideOthersMenuItem(), tk.createUnhideAllMenuItem(), new SeparatorMenuItem(),
tk.createQuitMenuItem(MainApp.APP_NAME));
tk.setApplicationMenu(defaultApplicationMenu);
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("macHide"));
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
helpMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
}
}
public void showAbout(ActionEvent event) {
Stage aboutStage = getAboutStage();
aboutStage.show();
}
private Stage getAboutStage() {
try {
FXMLLoader loader = new FXMLLoader(AppController.class.getResource("about.fxml"));
Parent root = loader.load();
AboutController controller = loader.getController();
Stage stage = new Stage();
stage.setTitle("About " + MainApp.APP_NAME);
stage.initStyle(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? StageStyle.UNDECORATED : StageStyle.DECORATED);
stage.setResizable(false);
stage.setScene(new Scene(root));
controller.setStage(stage);
controller.initializeView();
setStageIcon(stage);
return stage;
} catch(IOException e) {
log.error("Error loading about stage", e);
}
return null;
}
public void openTransactionFromFile(ActionEvent event) {
Stage window = new Stage();
@@ -430,7 +543,8 @@ public class AppController implements Initializable {
transactionReferenceService.setOnFailed(failEvent -> {
Platform.runLater(() -> {
Throwable e = failEvent.getSource().getException();
showErrorDialog("Error fetching transaction", e.getCause() != null ? e.getCause().getMessage() : e.getMessage());
log.error("Error fetching transaction " + txId.toString(), e);
showErrorDialog("Error fetching transaction", "The server returned an error when fetching the transaction. The server response is contained in sparrow.log");
});
});
transactionReferenceService.start();
@@ -467,13 +581,9 @@ public class AppController implements Initializable {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
Transaction transaction = transactionTabData.getTransaction();
//Note the transactionTabData's transaction does not change even once the final tx is extracted, so extract it here if possible
if(transactionTabData.getPsbt() != null && transactionTabData.getPsbt().isFinalized()) {
transaction = transactionTabData.getPsbt().extractTransaction();
}
//Save a transaction if the PSBT is null or finalized (a finalized PSBT is less useful than a broadcastable tx)
boolean saveTx = (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().isFinalized());
//Save a transaction if the PSBT is null or transaction has already been extracted, otherwise save PSBT
//The PSBT's transaction is not altered with transaction extraction, but the extracted transaction is stored in TransactionData
boolean saveTx = (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transaction);
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -481,8 +591,10 @@ public class AppController implements Initializable {
String fileName = selectedTab.getText();
if(fileName != null && !fileName.isEmpty()) {
if(transactionTabData.getPsbt() != null && !fileName.endsWith(".psbt")) {
fileName += ".psbt";
if(transactionTabData.getPsbt() != null) {
if(!fileName.endsWith(".psbt")) {
fileName += ".psbt";
}
} else if(!fileName.endsWith(".txn")) {
fileName += ".txn";
}
@@ -516,6 +628,14 @@ public class AppController implements Initializable {
return onlineProperty.get();
}
public void connect() {
serverToggle.selectedProperty().set(true);
}
public void disconnect() {
serverToggle.selectedProperty().set(false);
}
public static BooleanProperty onlineProperty() { return onlineProperty; }
public static Integer getCurrentBlockHeight() {
@@ -526,6 +646,10 @@ public class AppController implements Initializable {
return targetBlockFeeRates;
}
public static Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate;
}
public static CurrencyRate getFiatCurrencyExchangeRate() {
return fiatCurrencyExchangeRate;
}
@@ -550,12 +674,27 @@ public class AppController implements Initializable {
public static void showErrorDialog(String title, String content) {
Alert alert = new Alert(Alert.AlertType.ERROR);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
alert.setContentText(content);
alert.showAndWait();
}
public static void setStageIcon(Window window) {
Stage stage = (Stage)window;
stage.getIcons().add(new Image(AppController.class.getResourceAsStream("/image/sparrow.png")));
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
stage.getScene().getStylesheets().add(AppController.class.getResource("darktheme.css").toExternalForm());
}
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
}
public void selectTab(Wallet wallet) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
@@ -598,6 +737,10 @@ public class AppController implements Initializable {
return FileType.JSON.equals(fileType) || FileType.BINARY.equals(fileType);
}
private void setServerToggleTooltip() {
serverToggle.setTooltip(new Tooltip(isOnline() ? "Connected to " + Config.get().getElectrumServer() + (getCurrentBlockHeight() != null ? " at height " + getCurrentBlockHeight() : "") : "Disconnected"));
}
public void newWallet(ActionEvent event) {
WalletNameDialog dlg = new WalletNameDialog();
Optional<String> walletName = dlg.showAndWait();
@@ -628,7 +771,11 @@ public class AppController implements Initializable {
FileType fileType = IOUtils.getFileType(file);
if(FileType.JSON.equals(fileType)) {
Wallet wallet = storage.loadWallet();
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(wallet, null);
if(!wallet.isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
Tab tab = addWalletTab(storage, wallet);
tabs.getSelectionModel().select(tab);
} else if(FileType.BINARY.equals(fileType)) {
@@ -644,10 +791,11 @@ public class AppController implements Initializable {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Storage.WalletAndKey walletAndKey = loadWalletService.getValue();
try {
checkWalletNetwork(walletAndKey.wallet);
restorePublicKeysFromSeed(walletAndKey.wallet, walletAndKey.key);
Tab tab = addWalletTab(storage, walletAndKey.wallet);
tabs.getSelectionModel().select(tab);
} catch(MnemonicException e) {
} catch(Exception e) {
showErrorDialog("Error Opening Wallet", e.getMessage());
} finally {
walletAndKey.key.clear();
@@ -659,8 +807,11 @@ public class AppController implements Initializable {
if(exception instanceof InvalidPasswordException) {
showErrorDialog("Invalid Password", "The wallet password was invalid.");
} else {
log.error("Error Opening Wallet", exception);
showErrorDialog("Error Opening Wallet", exception.getMessage());
if(!attemptImportWallet(file, password)) {
log.error("Error Opening Wallet", exception);
showErrorDialog("Error Opening Wallet", exception.getMessage() == null ? "Unsupported file format" : exception.getMessage());
}
password.clear();
}
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
@@ -669,8 +820,16 @@ public class AppController implements Initializable {
throw new IOException("Unsupported file type");
}
} catch(Exception e) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage());
if(!attemptImportWallet(file, null)) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage() == null ? "Unsupported file format" : e.getMessage());
}
}
}
private void checkWalletNetwork(Wallet wallet) {
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
}
}
@@ -721,13 +880,67 @@ public class AppController implements Initializable {
Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get();
File walletFile = Storage.getWalletFile(wallet.getName());
Storage storage = new Storage(walletFile);
Tab tab = addWalletTab(storage, wallet);
tabs.getSelectionModel().select(tab);
addImportedWallet(wallet);
}
}
private boolean attemptImportWallet(File file, SecureString password) {
List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(), new Electrum(), new Specter(), new CoboVaultSinglesig(), new CoboVaultMultisig());
for(WalletImport importer : walletImporters) {
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> optionalPassword = dlg.showAndWait();
if(optionalPassword.isPresent()) {
password = optionalPassword.get();
}
}
Wallet wallet = importer.importWallet(inputStream, password == null ? null: password.asString());
if(wallet.getName() == null) {
wallet.setName(file.getName());
}
addImportedWallet(wallet);
return true;
} catch(Exception e) {
//ignore
}
}
return false;
}
private void addImportedWallet(Wallet wallet) {
File walletFile = Storage.getWalletFile(wallet.getName());
if(walletFile.exists()) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("Existing wallet found");
alert.setHeaderText("Replace existing wallet?");
alert.setContentText("Wallet file " + wallet.getName() + " already exists");
Optional<ButtonType> result = alert.showAndWait();
if(result.isPresent() && result.get() == ButtonType.CANCEL) {
return;
}
//Close existing wallet first if open
for(Iterator<Tab> iter = tabs.getTabs().iterator(); iter.hasNext(); ) {
Tab tab = iter.next();
TabData tabData = (TabData)tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
if(walletTabData.getStorage().getWalletFile().equals(walletFile)) {
iter.remove();
}
}
}
}
Storage storage = new Storage(walletFile);
Tab tab = addWalletTab(storage, wallet);
tabs.getSelectionModel().select(tab);
}
public void exportWallet(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
@@ -746,15 +959,44 @@ public class AppController implements Initializable {
preferencesDialog.showAndWait();
}
public void signVerifyMessage(ActionEvent event) {
MessageSignDialog messageSignDialog = null;
Tab tab = tabs.getSelectionModel().getSelectedItem();
if(tab != null && tab.getUserData() instanceof WalletTabData) {
WalletTabData walletTabData = (WalletTabData)tab.getUserData();
Wallet wallet = walletTabData.getWallet();
if(wallet.getKeystores().size() == 1 &&
(wallet.getKeystores().get(0).hasSeed() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
//Can sign and verify
messageSignDialog = new MessageSignDialog(wallet);
}
}
if(messageSignDialog == null) {
//Can verify only
messageSignDialog = new MessageSignDialog();
}
messageSignDialog.showAndWait();
}
public Tab addWalletTab(Storage storage, Wallet wallet) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
if(storage.getWalletFile().equals(walletTabData.getStorage().getWalletFile())) {
return tab;
}
}
}
try {
String name = storage.getWalletFile().getName();
if(name.endsWith(".json")) {
name = name.substring(0, name.lastIndexOf('.'));
}
Tab tab = new Tab(name);
TabData tabData = new WalletTabData(TabData.TabType.WALLET, wallet, storage);
tab.setUserData(tabData);
tab.setContextMenu(getTabContextMenu(tab));
tab.setClosable(true);
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
@@ -766,6 +1008,9 @@ public class AppController implements Initializable {
EventManager.get().register(walletForm);
controller.setWalletForm(walletForm);
TabData tabData = new WalletTabData(TabData.TabType.WALLET, walletForm);
tab.setUserData(tabData);
tabs.getTabs().add(tab);
return tab;
} catch(IOException e) {
@@ -862,29 +1107,30 @@ public class AppController implements Initializable {
}
Tab tab = new Tab(tabName);
TabData tabData = new TransactionTabData(TabData.TabType.TRANSACTION, transaction, psbt);
tab.setUserData(tabData);
tab.setContextMenu(getTabContextMenu(tab));
tab.setClosable(true);
FXMLLoader transactionLoader = new FXMLLoader(getClass().getResource("transaction/transaction.fxml"));
tab.setContent(transactionLoader.load());
TransactionController controller = transactionLoader.getController();
TransactionData transactionData;
if(psbt != null) {
controller.setPSBT(psbt);
transactionData = new TransactionData(name, psbt);
} else if(blockTransaction != null) {
controller.setBlockTransaction(blockTransaction);
transactionData = new TransactionData(name, blockTransaction);
} else {
controller.setTransaction(transaction);
transactionData = new TransactionData(name, transaction);
}
controller.setName(name);
controller.setTransactionData(transactionData);
if(initialView != null) {
controller.setInitialView(initialView, initialIndex);
}
controller.initializeView();
TabData tabData = new TransactionTabData(TabData.TabType.TRANSACTION, transactionData);
tab.setUserData(tabData);
tabs.getTabs().add(tab);
return tab;
@@ -917,6 +1163,19 @@ public class AppController implements Initializable {
return contextMenu;
}
public void setTheme(ActionEvent event) {
Theme selectedTheme = (Theme)theme.getSelectedToggle().getUserData();
if(Config.get().getTheme() != selectedTheme) {
Config.get().setTheme(selectedTheme);
}
if(selectedTheme == Theme.DARK) {
tabs.getScene().getStylesheets().add(getClass().getResource("darktheme.css").toExternalForm());
} else {
tabs.getScene().getStylesheets().remove(getClass().getResource("darktheme.css").toExternalForm());
}
}
@Subscribe
public void tabSelected(TabSelectedEvent event) {
String tabName = event.getTabName();
@@ -929,7 +1188,7 @@ public class AppController implements Initializable {
TransactionTabSelectedEvent txTabEvent = (TransactionTabSelectedEvent)event;
TransactionTabData transactionTabData = txTabEvent.getTransactionTabData();
saveTransaction.setDisable(false);
saveTransaction.setText("Save " + (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().isFinalized() ? "Transaction..." : "PSBT..."));
saveTransaction.setText("Save " + (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction() ? "Transaction..." : "PSBT..."));
exportWallet.setDisable(true);
showTxHex.setDisable(false);
} else if(event instanceof WalletTabSelectedEvent) {
@@ -943,12 +1202,12 @@ public class AppController implements Initializable {
}
@Subscribe
public void psbtFinalizedEvent(PSBTFinalizedEvent event) {
public void transactionExtractedEvent(TransactionExtractedEvent event) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData instanceof TransactionTabData) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
if(Arrays.equals(transactionTabData.getTransaction().bitcoinSerialize(), event.getPsbt().getTransaction().bitcoinSerialize())) {
if(transactionTabData.getTransaction() == event.getFinalTransaction()) {
saveTransaction.setText("Save Transaction...");
}
}
@@ -966,7 +1225,7 @@ public class AppController implements Initializable {
String text;
if(event.getBlockTransactions().size() == 1) {
BlockTransaction blockTransaction = event.getBlockTransactions().get(0);
if(blockTransaction.getHeight() == 0) {
if(blockTransaction.getHeight() <= 0) {
text = "New mempool transaction: ";
} else {
int confirmations = blockTransaction.getConfirmations(getCurrentBlockHeight());
@@ -982,11 +1241,11 @@ public class AppController implements Initializable {
text += event.getValueAsText(event.getTotalValue());
} else {
if(event.getTotalBlockchainValue() > 0 && event.getTotalMempoolValue() > 0) {
text = "New transactions: " + event.getValueAsText(event.getTotalValue()) + " (" + event.getValueAsText(event.getTotalMempoolValue()) + " in mempool)";
text = "New transactions: " + event.getValueAsText(event.getTotalValue()) + " total (" + event.getValueAsText(event.getTotalMempoolValue()) + " in mempool)";
} else if(event.getTotalMempoolValue() > 0) {
text = "New mempool transactions: " + event.getValueAsText(event.getTotalMempoolValue());
text = "New mempool transactions: " + event.getValueAsText(event.getTotalMempoolValue()) + " total";
} else {
text = "New transactions: " + event.getValueAsText(event.getTotalValue());
text = "New transactions: " + event.getValueAsText(event.getTotalValue()) + " total";
}
}
@@ -1002,11 +1261,16 @@ public class AppController implements Initializable {
.title("Sparrow - " + event.getWallet().getName())
.text(text)
.graphic(new ImageView(image))
.hideAfter(Duration.seconds(5))
.hideAfter(Duration.seconds(15))
.position(Pos.TOP_RIGHT)
.threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new ImageView(image)))
.onAction(e -> selectTab(event.getWallet()));
//If controlsfx can't find our window, we must set the window ourselves (unfortunately notification is then shown within this window)
if(org.controlsfx.tools.Utils.getWindow(null) == null) {
notificationBuilder.owner(tabs.getScene().getWindow());
}
notificationBuilder.show();
}
}
@@ -1015,7 +1279,7 @@ public class AppController implements Initializable {
public void statusUpdated(StatusEvent event) {
statusBar.setText(event.getStatus());
PauseTransition wait = new PauseTransition(Duration.seconds(10));
PauseTransition wait = new PauseTransition(Duration.seconds(20));
wait.setOnFinished((e) -> {
if(statusBar.getText().equals(event.getStatus())) {
statusBar.setText("");
@@ -1024,6 +1288,20 @@ public class AppController implements Initializable {
wait.play();
}
@Subscribe
public void versionUpdated(VersionUpdatedEvent event) {
Hyperlink versionUpdateLabel = new Hyperlink("Sparrow " + event.getVersion() + " available");
versionUpdateLabel.setOnAction(event1 -> {
application.getHostServices().showDocument("https://www.sparrowwallet.com/download");
});
if(statusBar.getRightItems().size() > 0 && statusBar.getRightItems().get(0) instanceof Hyperlink) {
statusBar.getRightItems().remove(0);
}
statusBar.getRightItems().add(0, versionUpdateLabel);
}
@Subscribe
public void timedWorker(TimedEvent event) {
if(event.getTimeMills() == 0) {
@@ -1067,7 +1345,7 @@ public class AppController implements Initializable {
} else {
if(usbStatus == null) {
usbStatus = new UsbStatusButton();
statusBar.getRightItems().add(0, usbStatus);
statusBar.getRightItems().add(Math.max(statusBar.getRightItems().size() - 1, 0), usbStatus);
} else {
usbStatus.getItems().remove(0, usbStatus.getItems().size());
}
@@ -1080,8 +1358,9 @@ public class AppController implements Initializable {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
targetBlockFeeRates = event.getTargetBlockFeeRates();
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
String banner = event.getServerBanner();
String status = "Connected: " + (banner == null ? "Server" : banner.split(System.lineSeparator(), 2)[0]) + " at height " + event.getBlockHeight();
String status = "Connected to " + Config.get().getElectrumServer() + " at height " + event.getBlockHeight();
EventManager.get().post(new StatusEvent(status));
}
@@ -1095,6 +1374,7 @@ public class AppController implements Initializable {
@Subscribe
public void newBlock(NewBlockEvent event) {
currentBlockHeight = event.getHeight();
setServerToggleTooltip();
String status = "Updating to new block height " + event.getHeight();
EventManager.get().post(new StatusEvent(status));
}
@@ -1137,9 +1417,19 @@ public class AppController implements Initializable {
fiatCurrencyExchangeRate = event.getCurrencyRate();
}
@Subscribe
public void versionCheckStatus(VersionCheckStatusEvent event) {
versionCheckService.cancel();
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled()) {
versionCheckService = createVersionCheckService();
versionCheckService.start();
}
}
@Subscribe
public void openWallets(OpenWalletsEvent event) {
List<File> walletFiles = event.getWalletsMap().values().stream().map(storage -> storage.getWalletFile()).collect(Collectors.toList());
List<File> walletFiles = event.getWalletsMap().values().stream().map(Storage::getWalletFile).collect(Collectors.toList());
Config.get().setRecentWalletFiles(walletFiles);
boolean usbWallet = false;
@@ -1191,4 +1481,14 @@ public class AppController implements Initializable {
public void requestQRScan(RequestQRScanEvent event) {
openTransactionFromQR(null);
}
@Subscribe
public void requestConnect(RequestConnectEvent event) {
connect();
}
@Subscribe
public void requestDisconnect(RequestDisconnectEvent event) {
disconnect();
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.Parameter;
import com.sparrowwallet.drongo.Network;
public class Args {
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
public String dir;
@Parameter(names = { "--network", "-n" }, description = "Network to use")
public Network network;
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
public boolean help;
}
@@ -1,6 +1,9 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.control.DescriptorArea;
import com.sparrowwallet.sparrow.control.ScriptArea;
import javafx.geometry.Point2D;
import javafx.scene.control.Label;
@@ -39,4 +42,42 @@ public abstract class BaseController {
protected String describeScriptChunk(ScriptChunk chunk) {
return chunk.toString();
}
protected void initializeDescriptorField(DescriptorArea descriptorArea) {
Popup popup = new Popup();
Label popupMsg = new Label();
popupMsg.getStyleClass().add("tooltip");
popup.getContent().add(popupMsg);
descriptorArea.setMouseOverTextDelay(Duration.ofMillis(150));
descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
TwoDimensional.Position position = descriptorArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
int index = descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE ? position.getMajor() - 1 : ((position.getMajor() - 1) / 2);
if(position.getMajor() > 0 && index >= 0 && index < descriptorArea.getWallet().getKeystores().size()) {
Keystore hoverKeystore = descriptorArea.getWallet().getKeystores().get(index);
Point2D pos = e.getScreenPosition();
popupMsg.setText(describeKeystore(hoverKeystore));
popup.show(descriptorArea, pos.getX(), pos.getY() + 10);
}
});
descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
popup.hide();
});
}
protected String describeKeystore(Keystore keystore) {
if(keystore.isValid()) {
StringBuilder builder = new StringBuilder();
builder.append("[");
builder.append(keystore.getKeyDerivation().getMasterFingerprint());
builder.append("/");
builder.append(keystore.getKeyDerivation().getDerivationPath().replaceFirst("^m?/", ""));
builder.append("]");
builder.append(keystore.getExtendedPublicKey().toString());
return builder.toString();
}
return "Invalid";
}
}
@@ -1,9 +1,13 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.control.WelcomeDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.FileType;
import com.sparrowwallet.sparrow.io.IOUtils;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import javafx.application.Application;
@@ -12,24 +16,39 @@ import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.GlyphFontRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.List;
import java.util.Optional;
import java.util.*;
import java.util.stream.Collectors;
public class MainApp extends Application {
private static final Logger log = LoggerFactory.getLogger(MainApp.class);
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "0.9.5";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
private Stage mainStage;
@Override
public void init() throws Exception {
Thread.setDefaultUncaughtExceptionHandler((t, e) -> LoggerFactory.getLogger(MainApp.class).error("Exception in thread \"" + t.getName() + "\"", e));
super.init();
}
@Override
public void start(Stage stage) throws Exception {
this.mainStage = stage;
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppController.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
boolean createNewWallet = false;
Mode mode = Config.get().getMode();
@@ -72,7 +91,14 @@ public class MainApp extends Application {
List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
if(recentWalletFiles != null) {
for(File walletFile : recentWalletFiles) {
//Resort to preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(file -> FileType.BINARY.equals(IOUtils.getFileType(file))).collect(Collectors.toList());
Collections.reverse(encryptedWalletFiles);
List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles);
sortedWalletFiles.removeAll(encryptedWalletFiles);
sortedWalletFiles.addAll(encryptedWalletFiles);
for(File walletFile : sortedWalletFiles) {
if(walletFile.exists()) {
Platform.runLater(() -> appController.openWalletFile(walletFile));
}
@@ -85,7 +111,37 @@ public class MainApp extends Application {
mainStage.close();
}
public static void main(String[] args) {
launch(args);
public static void main(String[] argv) {
Args args = new Args();
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase()).acceptUnknownOptions(true).build();
jCommander.parse(argv);
if(args.help) {
jCommander.usage();
System.exit(0);
}
if(args.dir != null) {
log.info("Using configured Sparrow home folder of " + args.dir);
System.setProperty(APP_HOME_PROPERTY, args.dir);
}
if(args.network != null) {
Network.set(args.network);
} else {
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
if(envNetwork != null) {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase()));
} catch(Exception e) {
log.warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
if(Network.get() != Network.MAINNET) {
log.info("Using " + Network.get() + " configuration");
}
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
}
}
@@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow;
import javafx.application.Preloader;
import javafx.stage.Stage;
public class MainAppPreloader extends Preloader {
@Override
public void start(Stage stage) {
com.sun.glass.ui.Application.GetApplication().setName("Sparrow");
}
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow;
public enum Theme {
LIGHT, DARK
}
@@ -2,26 +2,25 @@ package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.sparrow.transaction.TransactionData;
public class TransactionTabData extends TabData {
private final Transaction transaction;
private final PSBT psbt;
private final TransactionData transactionData;
public TransactionTabData(TabType type, Transaction transaction) {
this(type, transaction, null);
public TransactionTabData(TabType type, TransactionData transactionData) {
super(type);
this.transactionData = transactionData;
}
public TransactionTabData(TabType type, Transaction transaction, PSBT psbt) {
super(type);
this.transaction = transaction;
this.psbt = psbt;
public TransactionData getTransactionData() {
return transactionData;
}
public Transaction getTransaction() {
return transaction;
return transactionData.getTransaction();
}
public PSBT getPsbt() {
return psbt;
return transactionData.getPsbt();
}
}
@@ -1,34 +1,26 @@
package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.wallet.WalletForm;
public class WalletTabData extends TabData {
private Wallet wallet;
private final Storage storage;
private final WalletForm walletForm;
public WalletTabData(TabType type, Wallet wallet, Storage storage) {
public WalletTabData(TabType type, WalletForm walletForm) {
super(type);
this.wallet = wallet;
this.storage = storage;
this.walletForm = walletForm;
}
EventManager.get().register(this);
public WalletForm getWalletForm() {
return walletForm;
}
public Wallet getWallet() {
return wallet;
return walletForm.getWallet();
}
public Storage getStorage() {
return storage;
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
if(event.getWalletFile().equals(storage.getWalletFile())) {
wallet = event.getWallet();
}
return walletForm.getStorage();
}
}
@@ -32,7 +32,7 @@ public class AddressCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
Address address = utxoEntry.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor()));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), null));
Tooltip tooltip = new Tooltip();
tooltip.setText(getTooltipText(utxoEntry));
setTooltip(tooltip);
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ReceiveActionEvent;
import com.sparrowwallet.sparrow.event.ReceiveToEvent;
@@ -10,7 +11,6 @@ import javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
import javafx.scene.text.Font;
import java.util.List;
import java.util.Optional;
@@ -33,7 +33,7 @@ public class AddressTreeTable extends CoinTreeTable {
getColumns().add(addressCol);
if(address != null) {
addressCol.setMinWidth(TextUtils.computeTextWidth(Font.font("Courier"), address, 0.0));
addressCol.setMinWidth(TextUtils.computeTextWidth(AppController.getMonospaceFont(), address, 0.0));
}
TreeTableColumn<Entry, String> labelCol = new TreeTableColumn<>("Label");
@@ -39,6 +39,7 @@ public class BalanceChart extends LineChart<Number, Number> {
List<Data<Number, Number>> balanceDataList = walletTransactionsEntry.getChildren().stream()
.map(entry -> (TransactionEntry)entry)
.filter(txEntry -> txEntry.getBlockTransaction().getHeight() > 0)
.map(txEntry -> new XYChart.Data<>((Number)txEntry.getBlockTransaction().getDate().getTime(), (Number)txEntry.getBalance(), txEntry))
.collect(Collectors.toList());
@@ -83,7 +84,7 @@ public class BalanceChart extends LineChart<Number, Number> {
XYChart.Data<Number, Number> data = balanceSeries.getData().get(i);
Node symbol = lookup(".chart-line-symbol.data" + i);
if(symbol != null) {
if(data.getXValue().equals(transactionEntry.getBlockTransaction().getDate().getTime()) && data.getExtraValue() != null) {
if(transactionEntry.getBlockTransaction().getDate() != null && data.getXValue().equals(transactionEntry.getBlockTransaction().getDate().getTime()) && data.getExtraValue() != null) {
symbol.getStyleClass().add("selected");
selectedEntry = transactionEntry;
}
@@ -58,7 +58,7 @@ public class CoinLabel extends CopyableLabel {
setTooltip(tooltip);
setContextMenu(contextMenu);
String satsValue = String.format(Locale.ENGLISH, "%,d",value) + " sats";
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
String btcValue = BTC_FORMAT.format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
BitcoinUnit unit = bitcoinUnit;
@@ -4,13 +4,15 @@ import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextInputControl;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.ParseException;
import java.util.Locale;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
private static final Pattern COIN_VALIDATION = Pattern.compile("[\\d,]*(\\.\\d{0,8})?");
public static final DecimalFormat COIN_FORMAT = new DecimalFormat("###,###.########");
public static final DecimalFormat COIN_FORMAT = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
public CoinTextFormatter() {
super(new CoinFilter());
@@ -2,8 +2,11 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.application.Platform;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableView;
public class CoinTreeTable extends TreeTableView<Entry> {
@@ -33,4 +36,20 @@ public class CoinTreeTable extends TreeTableView<Entry> {
refresh();
}
}
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
Platform.runLater(() -> {
if(event.getErrorMessage() != null) {
setPlaceholder(new Label("Error loading transactions: " + event.getErrorMessage()));
} else if(event.isLoading()) {
if(event.getStatusMessage() != null) {
setPlaceholder(new Label(event.getStatusMessage() + "..."));
} else {
setPlaceholder(new Label("Loading transactions..."));
}
} else {
setPlaceholder(new Label("No transactions"));
}
});
}
}
@@ -54,7 +54,7 @@ public class ConfirmationProgressIndicator extends StackPane {
arcLengthTimeline.getKeyFrames().add(arcLengthFrame);
sequence.getChildren().add(arcLengthTimeline);
if(newValue.intValue() == BlockTransactionHash.BLOCKS_TO_CONFIRM) {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
Timeline arcRadiusTimeline = new Timeline();
KeyValue arcRadiusXValue = new KeyValue(arc.radiusXProperty(), 0.0);
KeyValue arcRadiusYValue = new KeyValue(arc.radiusYProperty(), 0.0);
@@ -33,9 +33,14 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
} else {
if(entry instanceof UtxoEntry) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
} else {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
}
Tooltip tooltip = new Tooltip();
int height = utxoEntry.getHashIndex().getHeight();
tooltip.setText(height > 0 ? Integer.toString(height) : "Mempool");
@@ -0,0 +1,89 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import org.fxmisc.richtext.CodeArea;
import java.util.List;
import static com.sparrowwallet.drongo.policy.PolicyType.MULTI;
import static com.sparrowwallet.drongo.policy.PolicyType.SINGLE;
import static com.sparrowwallet.drongo.protocol.ScriptType.MULTISIG;
public class DescriptorArea extends CodeArea {
private Wallet wallet;
public void setWallet(Wallet wallet) {
clear();
this.wallet = wallet;
DescriptorContextMenu contextMenu = new DescriptorContextMenu(wallet, this);
setContextMenu(contextMenu);
PolicyType policyType = wallet.getPolicyType();
ScriptType scriptType = wallet.getScriptType();
List<Keystore> keystores = wallet.getKeystores();
int threshold = wallet.getDefaultPolicy().getNumSignaturesRequired();
if(SINGLE.equals(policyType)) {
append(scriptType.getDescriptor(), "descriptor-text");
replace(getLength(), getLength(), keystores.get(0).getScriptName(), List.of(keystores.get(0).isValid() ? "descriptor-text" : "descriptor-error", keystores.get(0).getScriptName()));
append(scriptType.getCloseDescriptor(), "descriptor-text");
}
if(MULTI.equals(policyType)) {
append(scriptType.getDescriptor(), "descriptor-text");
append(MULTISIG.getDescriptor(), "descriptor-text");
append(Integer.toString(threshold), "descriptor-text");
for(Keystore keystore : keystores) {
append(",", "descriptor-text");
replace(getLength(), getLength(), keystore.getScriptName(), List.of(keystore.isValid() ? "descriptor-text" : "descriptor-error", keystore.getScriptName()));
}
append(MULTISIG.getCloseDescriptor(), "descriptor-text");
append(scriptType.getCloseDescriptor(), "descriptor-text");
}
}
public Wallet getWallet() {
return wallet;
}
public void clear() {
super.clear();
this.wallet = null;
setDisable(false);
setContextMenu(null);
}
private static class DescriptorContextMenu extends ContextMenu {
public DescriptorContextMenu(Wallet wallet, DescriptorArea descriptorArea) {
MenuItem copyvalue = new MenuItem("Copy Value");
copyvalue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(descriptorArea.getText());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyvalue);
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
copyOutputDescriptor.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(OutputDescriptor.getOutputDescriptor(wallet).toString(true));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyOutputDescriptor);
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: System; -fx-font-size: 1em;");
}
}
}
@@ -13,12 +13,15 @@ public class DeviceAddressDialog extends DeviceDialog<String> {
private final Wallet wallet;
private final KeyDerivation keyDerivation;
public DeviceAddressDialog(List<Device> devices, Wallet wallet, KeyDerivation keyDerivation) {
super(devices);
public DeviceAddressDialog(List<String> operationFingerprints, Wallet wallet, KeyDerivation keyDerivation) {
super(operationFingerprints);
this.wallet = wallet;
this.keyDerivation = keyDerivation;
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
@@ -28,8 +31,6 @@ public class DeviceAddressDialog extends DeviceDialog<String> {
@Subscribe
public void addressDisplayed(AddressDisplayedEvent event) {
EventManager.get().unregister(this);
setResult(event.getAddress());
this.close();
}
}
@@ -15,10 +15,12 @@ import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public abstract class DeviceDialog<R> extends Dialog<R> {
private final List<Device> operationDevices;
private final List<String> operationFingerprints;
private final Accordion deviceAccordion;
private final VBox scanBox;
private final Label scanLabel;
@@ -27,11 +29,12 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
this(null);
}
public DeviceDialog(List<Device> operationDevices) {
this.operationDevices = operationDevices;
public DeviceDialog(List<String> operationFingerprints) {
this.operationFingerprints = operationFingerprints;
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
@@ -103,16 +106,20 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
}
protected void setDevices(List<Device> devices) {
List<Device> dialogDevices = devices;
if(operationDevices != null && dialogDevices.containsAll(operationDevices)) {
dialogDevices = operationDevices;
List<Device> dialogDevices = new ArrayList<>(devices);
dialogDevices.removeIf(Objects::isNull);
if(operationFingerprints != null) {
dialogDevices.removeIf(device -> {
return device.getFingerprint() != null && !operationFingerprints.contains(device.getFingerprint()) && !(device.getNeedsPinSent() || device.getNeedsPassphraseSent());
});
}
deviceAccordion.getPanes().clear();
if(dialogDevices.isEmpty()) {
scanBox.setVisible(true);
scanLabel.setText("No devices found");
scanLabel.setText("No matching devices found");
} else {
scanBox.setVisible(false);
for(Device device : dialogDevices) {
@@ -10,11 +10,13 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -37,6 +39,7 @@ public class DevicePane extends TitledDescriptionPane {
private final Wallet wallet;
private final PSBT psbt;
private final KeyDerivation keyDerivation;
private final String message;
private final Device device;
private CustomPasswordField pinField;
@@ -46,6 +49,7 @@ public class DevicePane extends TitledDescriptionPane {
private SplitMenuButton importButton;
private Button signButton;
private Button displayAddressButton;
private Button signMessageButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
@@ -55,6 +59,7 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
setDefaultStatus();
@@ -80,6 +85,7 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = null;
this.psbt = psbt;
this.keyDerivation = null;
this.message = null;
this.device = device;
setDefaultStatus();
@@ -105,6 +111,7 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = keyDerivation;
this.message = null;
this.device = device;
setDefaultStatus();
@@ -124,6 +131,32 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
}
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = keyDerivation;
this.message = message;
this.device = device;
setDefaultStatus();
showHideLink.setVisible(false);
createSetPassphraseButton();
createSignMessageButton();
if (device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else {
showOperationButton();
}
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
@Override
protected Control createButton() {
createUnlockButton();
@@ -208,8 +241,24 @@ public class DevicePane extends TitledDescriptionPane {
}
}
private void createSignMessageButton() {
signMessageButton = new Button("Sign Message");
signMessageButton.setDefaultButton(true);
signMessageButton.setAlignment(Pos.CENTER_RIGHT);
signMessageButton.setOnAction(event -> {
signMessageButton.setDisable(true);
signMessage();
});
signMessageButton.managedProperty().bind(signMessageButton.visibleProperty());
signMessageButton.setVisible(false);
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
signMessageButton.setDisable(true);
}
}
private void unlock(Device device) {
if(device.getModel().equals(WalletModel.TREZOR_1)) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
}
}
@@ -273,6 +322,8 @@ public class DevicePane extends TitledDescriptionPane {
contentBox.getChildren().add(sendPassphraseButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
Platform.runLater(passphraseField::requestFocus);
return contentBox;
}
@@ -342,8 +393,7 @@ public class DevicePane extends TitledDescriptionPane {
if(device.getFingerprint() != null) {
setPassphraseButton.setVisible(false);
device.setNeedsPassphraseSent(false);
setDefaultStatus();
setDescription("Passphrase sent");
showOperationButton();
} else {
setError("Passphrase send failed", null);
@@ -436,6 +486,20 @@ public class DevicePane extends TitledDescriptionPane {
displayAddressService.start();
}
private void signMessage() {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
}
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
importButton.setVisible(true);
@@ -448,6 +512,9 @@ public class DevicePane extends TitledDescriptionPane {
} else if(deviceOperation.equals(DeviceOperation.DISPLAY_ADDRESS)) {
displayAddressButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.SIGN_MESSAGE)) {
signMessageButton.setVisible(true);
showHideLink.setVisible(false);
}
}
@@ -488,6 +555,6 @@ public class DevicePane extends TitledDescriptionPane {
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS;
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE;
}
}
@@ -11,10 +11,13 @@ import java.util.List;
public class DeviceSignDialog extends DeviceDialog<PSBT> {
private final PSBT psbt;
public DeviceSignDialog(List<Device> devices, PSBT psbt) {
super(devices);
public DeviceSignDialog(List<String> operationFingerprints, PSBT psbt) {
super(operationFingerprints);
this.psbt = psbt;
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
setResultConverter(dialogButton -> dialogButton.getButtonData().isCancelButton() ? null : psbt);
}
@@ -26,9 +29,7 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
@Subscribe
public void psbtSigned(PSBTSignedEvent event) {
if(psbt == event.getPsbt()) {
EventManager.get().unregister(this);
setResult(event.getSignedPsbt());
this.close();
}
}
}
@@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceSignMessageDialog extends DeviceDialog<String> {
private final Wallet wallet;
private final String message;
private final KeyDerivation keyDerivation;
public DeviceSignMessageDialog(List<String> operationFingerprints, Wallet wallet, String message, KeyDerivation keyDerivation) {
super(operationFingerprints);
this.wallet = wallet;
this.message = message;
this.keyDerivation = keyDerivation;
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, message, keyDerivation, device);
}
@Subscribe
public void messageSigned(MessageSignedEvent event) {
if(wallet == event.getWallet()) {
setResult(event.getSignature());
}
}
}
@@ -0,0 +1,24 @@
package com.sparrowwallet.sparrow.control;
import tornadofx.control.Form;
public class DynamicForm extends Form {
private DynamicUpdate dynamicUpdate;
public DynamicForm() {
super();
}
public void setDynamicUpdate(DynamicUpdate dynamicUpdate) {
this.dynamicUpdate = dynamicUpdate;
}
@Override
protected void layoutChildren() {
if(dynamicUpdate != null) {
dynamicUpdate.update();
}
super.layoutChildren();
}
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.control;
public interface DynamicUpdate {
void update();
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -43,9 +44,18 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
} else {
if(entry instanceof TransactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
String date = DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate());
setText(date);
setContextMenu(new TransactionContextMenu(date, transactionEntry.getBlockTransaction()));
if(transactionEntry.getBlockTransaction().getHeight() == -1) {
setText("Unconfirmed Parent");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry.getBlockTransaction()));
} else if(transactionEntry.getBlockTransaction().getHeight() == 0) {
setText("Unconfirmed");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry.getBlockTransaction()));
} else {
String date = DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate());
setText(date);
setContextMenu(new TransactionContextMenu(date, transactionEntry.getBlockTransaction()));
}
Tooltip tooltip = new Tooltip();
tooltip.setText(transactionEntry.getBlockTransaction().getHash().toString());
setTooltip(tooltip);
@@ -62,12 +72,13 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor()));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), null));
Tooltip tooltip = new Tooltip();
tooltip.setText(nodeEntry.getNode().getDerivationPath());
setTooltip(tooltip);
getStyleClass().add("address-cell");
HBox actionBox = new HBox();
Button receiveButton = new Button("");
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
receiveGlyph.setFontSize(12);
@@ -76,7 +87,23 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
setGraphic(receiveButton);
actionBox.getChildren().add(receiveButton);
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
(nodeEntry.getWallet().getKeystores().get(0).hasSeed() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
Button signMessageButton = new Button("");
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
signMessageButton.setGraphic(signMessageGlyph);
signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
actionBox.getChildren().add(signMessageButton);
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
}
setGraphic(actionBox);
} else if(entry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
setText(hashIndexEntry.getDescription());
@@ -95,7 +122,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
});
actionBox.getChildren().add(viewTransactionButton);
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && !hashIndexEntry.isSpent()) {
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable()) {
Button spendUtxoButton = new Button("");
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
sendGlyph.setFontSize(12);
@@ -105,7 +132,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
.map(tp -> tp.getTreeItem().getValue())
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && !hashIndexEntry.isSpent())
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
.collect(Collectors.toList());
if(!utxoEntries.contains(hashIndexEntry)) {
@@ -124,6 +151,20 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
}
}
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(BlockTransaction blockTransaction) {
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyTxid);
}
}
private static class TransactionContextMenu extends ContextMenu {
public TransactionContextMenu(String date, BlockTransaction blockTransaction) {
MenuItem copyDate = new MenuItem("Copy Date");
@@ -155,7 +196,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor) {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(AE -> {
hide();
@@ -181,6 +222,17 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
}
}
@@ -202,9 +254,12 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
public static void applyRowStyles(TreeTableCell<?, ?> cell, Entry entry) {
cell.getStyleClass().remove("transaction-row");
cell.getStyleClass().remove("node-row");
cell.getStyleClass().remove("utxo-row");
cell.getStyleClass().remove("address-cell");
cell.getStyleClass().remove("hashindex-row");
cell.getStyleClass().remove("confirming");
cell.getStyleClass().remove("spent");
cell.getStyleClass().remove("unspendable");
if(entry != null) {
if(entry instanceof TransactionEntry) {
@@ -222,6 +277,10 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
cell.getStyleClass().add("node-row");
} else if(entry instanceof UtxoEntry) {
cell.getStyleClass().add("utxo-row");
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(!utxoEntry.isSpendable()) {
cell.getStyleClass().add("unspendable");
}
} else if(entry instanceof HashIndexEntry) {
cell.getStyleClass().add("hashindex-row");
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
@@ -11,10 +11,12 @@ import javafx.scene.input.ClipboardContent;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Currency;
import java.util.Locale;
public class FiatLabel extends CopyableLabel {
private static final DecimalFormat CURRENCY_FORMAT = new DecimalFormat("#,##0.00");
private static final DecimalFormat CURRENCY_FORMAT = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final LongProperty valueProperty = new SimpleLongProperty(-1);
private final DoubleProperty btcRateProperty = new SimpleDoubleProperty(0.0);
@@ -28,7 +28,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(FileImportPane.class);
private final FileImport importer;
private Button importButton;
protected Button importButton;
private final SimpleStringProperty password = new SimpleStringProperty("");
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl) {
@@ -53,7 +53,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*.*"),
new FileChooser.ExtensionFilter("JSON", "*.json")
new FileChooser.ExtensionFilter("JSON", "*.json"),
new FileChooser.ExtensionFilter("TXT", "*.txt")
);
File file = fileChooser.showOpenDialog(window);
@@ -44,6 +44,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
fileChooser.setInitialFileName(wallet.getName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase() + "." + exporter.getExportFileExtension());
File file = fileChooser.showSaveDialog(window);
if(file != null) {
@@ -0,0 +1,106 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.io.ByteStreams;
import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreFileImport;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
public class FileWalletKeystoreImportPane extends FileImportPane {
private static final Logger log = LoggerFactory.getLogger(FileWalletKeystoreImportPane.class);
private final KeystoreFileImport importer;
private String fileName;
private byte[] fileBytes;
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
super(importer, importer.getName(), "Wallet file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
this.importer = importer;
}
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
this.fileName = fileName;
try {
fileBytes = ByteStreams.toByteArray(inputStream);
} catch(IOException e) {
throw new ImportException("Could not read file", e);
}
setContent(getScriptTypeEntry());
setExpanded(true);
importButton.setDisable(true);
}
private void importWallet(ScriptType scriptType) throws ImportException {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
Wallet wallet = new Wallet();
wallet.setName(fileName);
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
}
private Node getScriptTypeEntry() {
Label label = new Label("Script Type:");
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE)));
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
Button importFileButton = new Button("Import");
importFileButton.setOnAction(event -> {
showHideLink.setVisible(true);
setExpanded(false);
try {
importWallet(scriptTypeComboBox.getValue());
} catch(ImportException e) {
log.error("Error importing file", e);
String errorMessage = e.getMessage();
if(e.getCause() instanceof JsonParseException) {
errorMessage = "File was not in JSON format";
} else if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Import Error", errorMessage);
importButton.setDisable(false);
}
});
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.CENTER_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().addAll(label, scriptTypeComboBox, region, importFileButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
return contentBox;
}
}
@@ -36,7 +36,7 @@ public class HelpLabel extends Label {
private StringProperty helpText;
public final void setHelpText(String value) {
helpTextProperty().setValue(value);
helpTextProperty().setValue(value.replace("\\n", "\n"));
}
public final String getHelpText() {
@@ -1,6 +1,6 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.text.Font;
import com.sparrowwallet.sparrow.AppController;
public class IdLabel extends CopyableLabel {
public IdLabel() {
@@ -9,6 +9,6 @@ public class IdLabel extends CopyableLabel {
public IdLabel(String text) {
super(text);
setFont(Font.font("Courier"));
setFont(AppController.getMonospaceFont());
}
}
@@ -26,6 +26,7 @@ public class KeystorePassphraseDialog extends Dialog<String> {
setTitle("Keystore Passphrase" + (walletName != null ? " - " + walletName : ""));
dialogPane.setHeaderText("Please enter the passphrase for keystore: \n" + keystore.getLabel());
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(200);
@@ -0,0 +1,329 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
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.ECKey;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppController;
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.io.Storage;
import javafx.application.Platform;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.security.SignatureException;
import java.util.List;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppController.setStageIcon;
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
private final TextField address;
private final TextArea message;
private final TextArea signature;
private final Wallet wallet;
private WalletNode walletNode;
/**
* Verification only constructor
*/
public MessageSignDialog() {
this(null, null);
}
/**
* Sign and verify with user entered address
*
* @param wallet Wallet to sign with
*/
public MessageSignDialog(Wallet wallet) {
this(wallet, null);
}
/**
* Sign and verify with preset address
*
* @param wallet Wallet to sign with
* @param walletNode Wallet node to derive address from
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode) {
if(wallet != null) {
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).hasSeed() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore");
}
}
this.wallet = wallet;
this.walletNode = walletNode;
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(wallet == null ? "Verify Message" : "Sign/Verify Message");
Image image = new Image("image/seed.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
VBox vBox = new VBox();
vBox.setSpacing(20);
Form form = new Form();
Fieldset fieldset = new Fieldset();
fieldset.setText("");
Field addressField = new Field();
addressField.setText("Address:");
address = new TextField();
address.getStyleClass().add("id");
address.setEditable(walletNode == null);
addressField.getInputs().add(address);
if(walletNode != null) {
address.setText(wallet.getAddress(walletNode).toString());
}
Field messageField = new Field();
messageField.setText("Message:");
message = new TextArea();
message.setWrapText(true);
message.setPrefRowCount(10);
message.setStyle("-fx-pref-height: 180px");
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.setWrapText(true);
signatureField.getInputs().add(signature);
fieldset.getChildren().addAll(addressField, messageField, signatureField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
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.OK_DONE);
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
Button signButton = (Button)dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setOnAction(event -> {
signMessage();
});
Button verifyButton = (Button)dialogPane.lookupButton(verifyButtonType);
verifyButton.setDefaultButton(false);
verifyButton.setOnAction(event -> {
verifyMessage();
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
} catch(InvalidAddressException e) {
//can't happen
}
}
});
EventManager.get().register(this);
setOnCloseRequest(event -> {
if(ButtonBar.ButtonData.APPLY.equals(getResult())) {
event.consume();
return;
}
EventManager.get().unregister(this);
});
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
}
private Address getAddress()throws InvalidAddressException {
return Address.fromString(address.getText());
}
private boolean isValidAddress() {
try {
getAddress();
return true;
} catch (InvalidAddressException e) {
return false;
}
}
private void setWalletNodeFromAddress(Wallet wallet, Address address) {
walletNode = wallet.getWalletAddresses().get(address);
}
private void signMessage() {
if(walletNode == null) {
AppController.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
if(wallet.containsSeeds()) {
if(wallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
signUnencryptedKeystore(wallet);
}
} else if(wallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(wallet);
}
}
private void signUnencryptedKeystore(Wallet decryptedWallet) {
try {
//Note we can expect a single keystore due to the check above
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
signature.clear();
signature.appendText(signatureText);
} catch(Exception e) {
log.error("Could not sign message", e);
AppController.showErrorDialog("Could not sign message", e.getMessage());
}
}
private void signUsbKeystore(Wallet usbWallet) {
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation);
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
signature.clear();
signature.appendText(optSignature.get());
}
}
private void verifyMessage() {
try {
//Find ECKey from message and signature
//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);
verified = verifyMessage(signedMessageKey);
} catch(SignatureException e) {
//ignore
}
if(!verified) {
try {
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
verified = verifyMessage(electrumSignedMessageKey);
} catch(SignatureException e) {
//ignore
}
}
if(verified) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.setTitle("Verification Succeeded");
alert.setHeaderText("Verification Succeeded");
alert.setContentText("The signature verified against the message.");
alert.showAndWait();
} else {
AppController.showErrorDialog("Verification failed", "The provided signature did not match the message for this address.");
}
} catch(IllegalArgumentException e) {
AppController.showErrorDialog("Could not verify message", e.getMessage());
} catch(Exception e) {
log.error("Could not verify message", e);
AppController.showErrorDialog("Could not verify message", e.getMessage());
}
}
private boolean verifyMessage(ECKey signedMessageKey) throws InvalidAddressException, SignatureException {
Address providedAddress = getAddress();
ScriptType scriptType = providedAddress.getScriptType();
if(scriptType == ScriptType.P2SH) {
scriptType = ScriptType.P2SH_P2WPKH;
}
if(!ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).contains(scriptType)) {
throw new IllegalArgumentException("Only single signature P2PKH, P2SH-P2WPKH or P2WPKH addresses can verify messages.");
}
Address signedMessageAddress = scriptType.getAddress(signedMessageKey);
return providedAddress.equals(signedMessageAddress);
}
@Subscribe
public void openWallets(OpenWalletsEvent event) {
Storage storage = event.getStorage(wallet);
if(storage == null) {
throw new IllegalStateException("Wallet " + wallet + " without Storage");
}
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
signUnencryptedKeystore(decryptedWallet);
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
AppController.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
}
}
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleStringProperty;
@@ -21,6 +22,7 @@ import javafx.util.Callback;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
@@ -29,6 +31,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
@@ -39,7 +42,11 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private SplitMenuButton importButton;
private TilePane wordsPane;
private Button verifyButton;
private Button generateButton;
private Label validLabel;
private Label invalidLabel;
private Button calculateButton;
private Button backButton;
private Button confirmButton;
private List<String> generatedMnemonicCode;
@@ -47,7 +54,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer) {
super(importer.getName(), "Mnemonic import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.importer = importer;
@@ -56,7 +63,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
public MnemonicKeystoreImportPane(Keystore keystore) {
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase enabled" : "Passphrase disabled", "", "image/" + WalletModel.SEED + ".png");
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase enabled" : "Passphrase disabled", "", "image/" + WalletModel.SEED.getType() + ".png");
this.wallet = null;
this.importer = null;
showHideLink.setVisible(false);
@@ -74,7 +81,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private void createEnterMnemonicButton() {
enterMnemonicButton = new SplitMenuButton();
enterMnemonicButton.setAlignment(Pos.CENTER_RIGHT);
enterMnemonicButton.setText("Enter Mnemonic");
enterMnemonicButton.setText("Set Words Size");
enterMnemonicButton.setOnAction(event -> {
enterMnemonic(24);
});
@@ -148,49 +155,102 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
if(!displayWordsOnly) {
PassphraseEntry passphraseEntry = new PassphraseEntry();
passphraseEntry.setPadding(new Insets(0, 32, 0, 10));
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
AnchorPane buttonPane = new AnchorPane();
buttonPane.setPadding(new Insets(0, 32, 0, 10));
buttonPane.setPadding(new Insets(0, 26, 0, 10));
Button generateButton = new Button("Generate New");
generateButton = new Button("Generate New");
generateButton.setOnAction(event -> {
generateNew();
});
generateButton.managedProperty().bind(generateButton.visibleProperty());
generateButton.setTooltip(new Tooltip("Generate a unique set of words that provide the seed for your wallet"));
buttonPane.getChildren().add(generateButton);
AnchorPane.setLeftAnchor(generateButton, 0.0);
confirmButton = new Button("Confirm Backup");
validLabel = new Label("Valid checksum", getValidGlyph());
validLabel.setContentDisplay(ContentDisplay.LEFT);
validLabel.setGraphicTextGap(5.0);
validLabel.managedProperty().bind(validLabel.visibleProperty());
validLabel.setVisible(false);
buttonPane.getChildren().add(validLabel);
AnchorPane.setTopAnchor(validLabel, 5.0);
AnchorPane.setLeftAnchor(validLabel, 0.0);
invalidLabel = new Label("Invalid checksum", getInvalidGlyph());
invalidLabel.setContentDisplay(ContentDisplay.LEFT);
invalidLabel.setGraphicTextGap(5.0);
invalidLabel.managedProperty().bind(invalidLabel.visibleProperty());
invalidLabel.setVisible(false);
buttonPane.getChildren().add(invalidLabel);
AnchorPane.setTopAnchor(invalidLabel, 5.0);
AnchorPane.setLeftAnchor(invalidLabel, 0.0);
confirmButton = new Button("Re-enter Words...");
confirmButton.setOnAction(event -> {
confirmBackup();
});
confirmButton.managedProperty().bind(confirmButton.visibleProperty());
confirmButton.setVisible(false);
confirmButton.setDefaultButton(true);
confirmButton.setTooltip(new Tooltip("Write down the words above as a backup - you will need to re-enter them to confirm your backup is correct"));
buttonPane.getChildren().add(confirmButton);
AnchorPane.setRightAnchor(confirmButton, 0.0);
verifyButton = new Button("Verify");
verifyButton.setDisable(true);
verifyButton.setDefaultButton(true);
verifyButton.setOnAction(event -> {
calculateButton = new Button("Calculate Seed");
calculateButton.setDisable(true);
calculateButton.setDefaultButton(true);
calculateButton.setOnAction(event -> {
prepareImport();
});
verifyButton.managedProperty().bind(verifyButton.visibleProperty());
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
calculateButton.setTooltip(new Tooltip("Calculate the seed from the provided word list"));
backButton = new Button("Back");
backButton.setOnAction(event -> {
displayMnemonicCode();
});
backButton.managedProperty().bind(backButton.visibleProperty());
backButton.setTooltip(new Tooltip("Go back to the generated word list"));
backButton.setVisible(false);
wordEntriesProperty.addListener((ListChangeListener<String>) c -> {
boolean empty = true;
boolean validWords = true;
boolean validChecksum = false;
for(String word : wordEntryList) {
if(!word.isEmpty()) {
empty = false;
}
if(!WordEntry.isValid(word)) {
verifyButton.setDisable(true);
return;
validWords = false;
}
}
verifyButton.setDisable(false);
if(!empty && validWords) {
try {
importer.getKeystore(wallet.getScriptType().getDefaultDerivation(), wordEntriesProperty.get(), passphraseProperty.get());
validChecksum = true;
} catch(ImportException e) {
//ignore
}
}
generateButton.setVisible(empty && generatedMnemonicCode == null);
calculateButton.setDisable(!validChecksum);
validLabel.setVisible(validChecksum);
invalidLabel.setVisible(!validChecksum && !empty);
});
buttonPane.getChildren().add(verifyButton);
AnchorPane.setRightAnchor(verifyButton, 0.0);
HBox rightBox = new HBox();
rightBox.setSpacing(10);
rightBox.getChildren().addAll(backButton, calculateButton);
buttonPane.getChildren().add(rightBox);
AnchorPane.setRightAnchor(rightBox, 0.0);
vBox.getChildren().add(buttonPane);
}
@@ -199,14 +259,23 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
private void generateNew() {
setDescription("Write down word list to confirm backup");
showHideLink.setVisible(false);
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
DeterministicSeed deterministicSeed = new DeterministicSeed(new SecureRandom(), entropyLength, "");
generatedMnemonicCode = deterministicSeed.getMnemonicCode();
displayMnemonicCode();
}
private void displayMnemonicCode() {
setDescription("Write down words before re-entering");
showHideLink.setVisible(false);
calculateButton.setVisible(false);
confirmButton.setVisible(true);
backButton.setVisible(false);
if(generatedMnemonicCode.size() != wordsPane.getChildren().size()) {
throw new IllegalStateException("Generated mnemonic words list not same size as displayed words list");
}
@@ -216,9 +285,6 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
wordEntry.getEditor().setText(generatedMnemonicCode.get(i));
wordEntry.getEditor().setEditable(false);
}
verifyButton.setVisible(false);
confirmButton.setVisible(true);
}
private void confirmBackup() {
@@ -226,6 +292,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), false));
setExpanded(true);
backButton.setVisible(true);
generateButton.setVisible(false);
}
private void prepareImport() {
@@ -235,7 +303,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
if(importKeystore(wallet.getScriptType().getDefaultDerivation(), true)) {
setExpanded(false);
setExpanded(true);
enterMnemonicButton.setVisible(false);
importButton.setVisible(true);
importButton.setDisable(false);
@@ -277,13 +345,14 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
setSpacing(10);
Label label = new Label((wordNumber+1) + ".");
label.setPrefWidth(20);
label.setPrefWidth(22);
label.setAlignment(Pos.CENTER_RIGHT);
wordField = new TextField();
wordField.setMaxWidth(100);
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
autoCompletionBinding.setDelay(50);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.registerValidator(wordField, Validator.combine(
@@ -320,6 +389,10 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
List<String> suggestions = new ArrayList<>();
if(!request.getUserText().isEmpty()) {
for(String word : wordList) {
if(word.equals(request.getUserText())) {
return Collections.emptyList();
}
if(word.startsWith(request.getUserText())) {
suggestions.add(word);
}
@@ -341,7 +414,11 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
passphraseProperty.bind(passphraseField.textProperty());
passphraseField.setPromptText("Leave blank for none");
getChildren().addAll(passphraseLabel, passphraseField);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setStyle("-fx-padding: 0 0 0 0");
helpLabel.setHelpText("A passphrase provides optional added security - it is not stored so it must be remembered!");
getChildren().addAll(passphraseLabel, passphraseField, helpLabel);
}
}
@@ -392,4 +469,18 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
wordEntry.getEditor().setEditable(false);
}
}
public static Glyph getValidGlyph() {
Glyph validGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE);
validGlyph.getStyleClass().add("valid-checksum");
validGlyph.setFontSize(12);
return validGlyph;
}
public static Glyph getInvalidGlyph() {
Glyph invalidGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
invalidGlyph.getStyleClass().add("invalid-checksum");
invalidGlyph.setFontSize(12);
return invalidGlyph;
}
}
@@ -5,7 +5,7 @@ import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.ur.UR;
import com.sparrowwallet.sparrow.ur.UREncoder;
@@ -48,12 +48,13 @@ public class QRDisplayDialog extends Dialog<UR> {
this.encoder = new UREncoder(ur, MAX_FRAGMENT_LENGTH, MIN_FRAGMENT_LENGTH, 0);
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
qrImageView = new ImageView();
stackPane.getChildren().add(qrImageView);
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().outerPadding(0).innerPadding(0).buildAll());
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
nextPart();
if(encoder.isSinglePart()) {
@@ -75,6 +76,28 @@ public class QRDisplayDialog extends Dialog<UR> {
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
}
public QRDisplayDialog(String data) {
this.ur = null;
this.encoder = null;
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
qrImageView = new ImageView();
stackPane.getChildren().add(qrImageView);
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
qrImageView.setImage(getQrCode(data));
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(550);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
}
private void nextPart() {
String fragment = encoder.nextPart();
currentPart = fragment.toUpperCase();
@@ -1,10 +1,14 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamResolution;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Base43;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.ur.ResultType;
import com.sparrowwallet.sparrow.ur.UR;
import com.sparrowwallet.sparrow.ur.URDecoder;
@@ -32,11 +36,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
WebcamView webcamView = new WebcamView(webcamService);
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
stackPane.getChildren().add(webcamView.getView());
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().outerPadding(0).innerPadding(0).buildAll());
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> {
@@ -71,7 +76,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
if(urResult.ur.getType().equals(UR.BYTES_TYPE)) {
//TODO: Confirm once UR type registry is updated
if(urResult.ur.getType().contains(UR.BYTES_TYPE) || urResult.ur.getType().equals(UR.CRYPTO_PSBT_TYPE)) {
try {
PSBT psbt = new PSBT(urResult.ur.toBytes());
result = new Result(psbt);
@@ -99,6 +105,33 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else {
PSBT psbt;
Transaction transaction;
BitcoinURI bitcoinURI;
Address address;
ExtendedKey extendedKey;
try {
extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey);
return;
} catch(Exception e) {
//Ignore, not a valid xpub
}
try {
bitcoinURI = new BitcoinURI(qrtext);
result = new Result(bitcoinURI);
return;
} catch(Exception e) {
//Ignore, not an BIP 21 URI
}
try {
address = Address.fromString(qrtext);
result = new Result(address);
return;
} catch(Exception e) {
//Ignore, not an address
}
try {
psbt = PSBT.fromString(qrtext);
result = new Result(psbt);
@@ -132,9 +165,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
//Try Base43 used by Electrum
byte[] base43 = Base43.decode(qrResult.getText());
try {
psbt = new PSBT(base43);
psbt = new PSBT(Base43.decode(qrResult.getText()));
result = new Result(psbt);
return;
} catch(Exception e) {
@@ -142,14 +174,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
try {
transaction = new Transaction(base43);
transaction = new Transaction(Base43.decode(qrResult.getText()));
result = new Result(transaction);
return;
} catch(Exception e) {
//Ignore, not parseable as base43 decoded bytes
}
result = new Result("Cannot parse QR code into a PSBT or transaction");
result = new Result("Cannot parse QR code into a PSBT, transaction or address");
}
}
}
@@ -157,12 +189,16 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public static class Result {
public final Transaction transaction;
public final PSBT psbt;
public final BitcoinURI uri;
public final ExtendedKey extendedKey;
public final String error;
public final Throwable exception;
public Result(Transaction transaction) {
this.transaction = transaction;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.exception = null;
}
@@ -170,6 +206,35 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public Result(PSBT psbt) {
this.transaction = null;
this.psbt = psbt;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.exception = null;
}
public Result(BitcoinURI uri) {
this.transaction = null;
this.psbt = null;
this.uri = uri;
this.extendedKey = null;
this.error = null;
this.exception = null;
}
public Result(Address address) {
this.transaction = null;
this.psbt = null;
this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null;
this.error = null;
this.exception = null;
}
public Result(ExtendedKey extendedKey) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = extendedKey;
this.error = null;
this.exception = null;
}
@@ -177,6 +242,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public Result(String error) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = error;
this.exception = null;
}
@@ -184,6 +251,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public Result(Throwable exception) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.exception = exception;
}
@@ -2,7 +2,6 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.sparrow.transaction.ScriptContextMenu;
import javafx.geometry.Pos;
import org.controlsfx.control.decoration.Decorator;
import org.controlsfx.control.decoration.GraphicDecoration;
@@ -1,4 +1,4 @@
package com.sparrowwallet.sparrow.transaction;
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
@@ -33,7 +33,7 @@ public class ScriptContextMenu extends ContextMenu {
});
getItems().add(copyvalue);
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: sans-serif; -fx-font-size: 1em;");
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: System; -fx-font-size: 1em;");
area.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
hoverChunk = null;
@@ -11,6 +11,7 @@ public class SeedDisplayDialog extends Dialog<Void> {
public SeedDisplayDialog(Keystore decryptedKeystore) {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
int lines = decryptedKeystore.getSeed().getMnemonicCode().size() / 3;
int height = lines * 40;
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.TransactionSignature;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreSignedEvent;
@@ -7,8 +8,8 @@ import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableMap;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
@@ -18,6 +19,9 @@ import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import org.controlsfx.control.SegmentedBar;
import java.util.ArrayList;
import java.util.List;
public class SignaturesProgressBar extends SegmentedBar<SignaturesProgressBar.SignatureProgressSegment> {
public SignaturesProgressBar() {
setOrientation(Orientation.HORIZONTAL);
@@ -25,10 +29,11 @@ public class SignaturesProgressBar extends SegmentedBar<SignaturesProgressBar.Si
setInfoNodeFactory(segment -> segment.getKeystore() == null ? null : new SignatureProgressSegmentLabel(segment.getKeystore().getLabel()));
}
public void initialize(ObservableList<Keystore> signedKeystores, int threshold) {
public void initialize(ObservableMap<TransactionSignature, Keystore> signatureKeystoreMap, int threshold) {
getStyleClass().add("signatures-progress-bar");
getSegments().clear();
List<Keystore> signedKeystores = new ArrayList<>(signatureKeystoreMap.values());
int numSegments = Math.max(threshold, signedKeystores.size());
double segmentSize = 100d / numSegments;
for(int i = 0; i < numSegments; i++) {
@@ -39,19 +44,20 @@ public class SignaturesProgressBar extends SegmentedBar<SignaturesProgressBar.Si
}
}
signedKeystores.addListener((ListChangeListener<Keystore>) c -> {
int numSegments1 = Math.max(threshold, c.getList().size());
double newSegmentSize = 100d / numSegments1;
signatureKeystoreMap.addListener((MapChangeListener<TransactionSignature, Keystore>) c -> {
List<Keystore> newSignedKeystores = new ArrayList<>(c.getMap().values());
int newNumSegments = Math.max(threshold, newSignedKeystores.size());
double newSegmentSize = 100d / newNumSegments;
for(int i = 0; i < numSegments1; i++) {
for(int i = 0; i < newNumSegments; i++) {
SignatureProgressSegment segment = null;
if(i < getSegments().size()) {
segment = getSegments().get(i);
}
Keystore signedKeystore = null;
if(i < signedKeystores.size()) {
signedKeystore = signedKeystores.get(i);
if(i < newSignedKeystores.size()) {
signedKeystore = newSignedKeystores.get(i);
}
if(segment != null) {
@@ -1,9 +1,11 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppController;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
public class TextAreaDialog extends Dialog<String> {
private final TextArea textArea;
@@ -20,11 +22,15 @@ public class TextAreaDialog extends Dialog<String> {
this.textArea = new TextArea(defaultValue);
this.textArea.setMaxWidth(Double.MAX_VALUE);
this.textArea.setWrapText(true);
this.textArea.getStyleClass().add("fixed-width");
hbox.getChildren().add(textArea);
HBox.setHgrow(this.textArea, Priority.ALWAYS);
this.defaultValue = defaultValue;
getDialogPane().setContent(hbox);
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getStyleClass().add("text-input-dialog");
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
@@ -35,6 +41,9 @@ public class TextAreaDialog extends Dialog<String> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? textArea.getText() : null;
});
dialogPane.setPrefWidth(700);
dialogPane.setPrefHeight(400);
}
public final TextArea getEditor() {
@@ -88,7 +88,7 @@ public class TitledDescriptionPane extends TitledPane {
this.layoutBoundsProperty().addListener((observable, oldValue, newValue) -> {
//Hack to force listItem to expand to full available width less border
listItem.setPrefWidth(newValue.getWidth() - 2);
listItem.setPrefWidth(newValue.getWidth() - 3);
});
return listItem;
@@ -125,7 +125,7 @@ public class TitledDescriptionPane extends TitledPane {
contentBox.setPadding(new Insets(10, 30, 10, 30));
double width = TextUtils.computeTextWidth(details.getFont(), message, 0.0D);
double numLines = Math.max(1, width / 400);
double numLines = Math.max(1, Math.ceil(width / 400d));
//Handle long words like txids
OptionalDouble maxWordLength = Arrays.stream(message.split(" ")).mapToDouble(word -> TextUtils.computeTextWidth(details.getFont(), message, 0.0D)).max();
@@ -133,7 +133,7 @@ public class TitledDescriptionPane extends TitledPane {
numLines += 1.0;
}
double height = Math.max(60, numLines * 40);
double height = Math.max(60, numLines * 20);
contentBox.setPrefHeight(height);
return contentBox;
@@ -4,16 +4,21 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.*;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Line;
import javafx.util.Duration;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
@@ -22,6 +27,7 @@ import java.util.*;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
private static final double DIAGRAM_HEIGHT = 230.0;
private static final int TOOLTIP_SHOW_DELAY = 50;
private WalletTransaction walletTx;
@@ -146,14 +152,24 @@ public class TransactionDiagram extends GridPane {
WalletNode walletNode = displayedUtxos.get(input);
String desc = getInputDescription(input);
Label label = new Label(desc);
label.getStyleClass().add("utxo-label");
Button excludeUtxoButton = new Button("");
excludeUtxoButton.setGraphic(getExcludeGlyph());
excludeUtxoButton.setOnAction(event -> {
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
});
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
Tooltip tooltip = new Tooltip();
if(walletNode != null) {
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath() + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
tooltip.getStyleClass().add("input-label");
if(input.getLabel() == null || input.getLabel().isEmpty()) {
label.getStyleClass().add("input-label");
} else {
tooltip.getStyleClass().add("input-label");
}
} else {
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex)input;
@@ -164,6 +180,7 @@ public class TransactionDiagram extends GridPane {
tooltip.setText(joiner.toString());
tooltip.getStyleClass().add("input-label");
}
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
label.setTooltip(tooltip);
inputsBox.getChildren().add(label);
@@ -270,10 +287,13 @@ public class TransactionDiagram extends GridPane {
outputsBox.setAlignment(Pos.CENTER_LEFT);
outputsBox.getChildren().add(createSpacer());
boolean isConsolidation = walletTx.isConsolidationSend();
String recipientDesc = walletTx.getRecipientAddress().toString().substring(0, 8) + "...";
Label recipientLabel = new Label(recipientDesc, getSendGlyph());
Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph());
recipientLabel.getStyleClass().addAll("output-label", "recipient-label");
Tooltip recipientTooltip = new Tooltip("Send " + getSatsValue(walletTx.getRecipientAmount()) + " sats to\n" + walletTx.getRecipientAddress().toString());
Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(walletTx.getRecipientAmount()) + " sats to\n" + walletTx.getRecipientAddress().toString());
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientLabel.setTooltip(recipientTooltip);
outputsBox.getChildren().add(recipientLabel);
outputsBox.getChildren().add(createSpacer());
@@ -283,6 +303,8 @@ public class TransactionDiagram extends GridPane {
Label changeLabel = new Label(changeDesc, getChangeGlyph());
changeLabel.getStyleClass().addAll("output-label", "change-label");
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath() + "\n" + walletTx.getChangeAddress().toString());
changeTooltip.getStyleClass().add("change-label");
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
changeLabel.setTooltip(changeTooltip);
outputsBox.getChildren().add(changeLabel);
outputsBox.getChildren().add(createSpacer());
@@ -294,6 +316,7 @@ public class TransactionDiagram extends GridPane {
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
Tooltip feeTooltip = new Tooltip("Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
feeTooltip.getStyleClass().add("fee-tooltip");
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
feeLabel.setTooltip(feeTooltip);
outputsBox.getChildren().add(feeLabel);
outputsBox.getChildren().add(createSpacer());
@@ -323,36 +346,50 @@ public class TransactionDiagram extends GridPane {
return spacer;
}
private Glyph getSendGlyph() {
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
sendGlyph.getStyleClass().add("send-icon");
sendGlyph.setFontSize(12);
return sendGlyph;
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;
}
private Glyph getChangeGlyph() {
Glyph changeGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.COINS);
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 getChangeGlyph() {
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
changeGlyph.getStyleClass().add("change-icon");
changeGlyph.setFontSize(12);
return changeGlyph;
}
private Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.HAND_HOLDING);
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("Font Awesome 5 Free Solid", FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getLockGlyph() {
Glyph lockGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.LOCK);
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
lockGlyph.getStyleClass().add("lock-icon");
lockGlyph.setFontSize(12);
return lockGlyph;
@@ -23,8 +23,9 @@ public class TransactionIdDialog extends Dialog<Sha256Hash> {
public TransactionIdDialog() {
this.txid = (CustomTextField) TextFields.createClearableTextField();
txid.setFont(Font.font ("Courier", txid.getFont().getSize()));
txid.setFont(AppController.getMonospaceFont());
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Load Transaction");
dialogPane.setHeaderText("Enter the transaction ID:");
@@ -62,7 +62,7 @@ public class UtxosChart extends BarChart<String, Number> {
}
if(utxoSeries.getData().size() > utxoDataList.size()) {
utxoSeries.getData().remove(utxoDataList.size() - 1, utxoSeries.getData().size());
utxoSeries.getData().remove(Math.max(0, utxoDataList.size() - 1), utxoSeries.getData().size());
}
if(selectedEntries != null) {
@@ -3,13 +3,10 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.ColdcardMultisig;
import com.sparrowwallet.sparrow.io.Electrum;
import com.sparrowwallet.sparrow.io.WalletExport;
import com.sparrowwallet.sparrow.io.WalletImport;
import com.sparrowwallet.sparrow.io.*;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
@@ -21,8 +18,12 @@ public class WalletExportDialog extends Dialog<Wallet> {
public WalletExportDialog(Wallet wallet) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
@@ -31,7 +32,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
stackPane.getChildren().add(anchorPane);
ScrollPane scrollPane = new ScrollPane();
scrollPane.setPrefHeight(280);
scrollPane.setPrefHeight(400);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
@@ -40,9 +41,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum());
exporters = List.of(new Electrum(), new Specter());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new ColdcardMultisig(), new Electrum());
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new Specter());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -57,16 +58,14 @@ public class WalletExportDialog extends Dialog<Wallet> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360);
dialogPane.setPrefHeight(480);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
}
@Subscribe
public void walletExported(WalletExportEvent event) {
EventManager.get().unregister(this);
wallet = event.getWallet();
setResult(wallet);
this.close();
}
}
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.*;
@@ -16,8 +17,12 @@ public class WalletImportDialog extends Dialog<Wallet> {
public WalletImportDialog() {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
@@ -26,16 +31,22 @@ public class WalletImportDialog extends Dialog<Wallet> {
stackPane.getChildren().add(anchorPane);
ScrollPane scrollPane = new ScrollPane();
scrollPane.setPrefHeight(280);
scrollPane.setPrefHeight(420);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
List<WalletImport> importers = List.of(new ColdcardMultisig(), new Electrum());
Accordion importAccordion = new Accordion();
for(WalletImport importer : importers) {
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
}
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new Specter());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
}
@@ -44,16 +55,14 @@ public class WalletImportDialog extends Dialog<Wallet> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360);
dialogPane.setPrefHeight(500);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
}
@Subscribe
public void walletImported(WalletImportEvent event) {
EventManager.get().unregister(this);
wallet = event.getWallet();
setResult(wallet);
this.close();
}
}
@@ -22,6 +22,7 @@ public class WalletNameDialog extends Dialog<String> {
public WalletNameDialog() {
this.name = (CustomTextField)TextFields.createClearableTextField();
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Wallet Name");
dialogPane.setHeaderText("Enter a name for this wallet:");
@@ -21,6 +21,7 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
private final CustomPasswordField password;
private final CustomPasswordField passwordConfirm;
private final CheckBox backupExisting;
private final CheckBox changePassword;
public WalletPasswordDialog(PasswordRequirement requirement) {
this(null, requirement);
@@ -31,11 +32,13 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
this.password = (CustomPasswordField)TextFields.createClearablePasswordField();
this.passwordConfirm = (CustomPasswordField)TextFields.createClearablePasswordField();
this.backupExisting = new CheckBox("Backup existing wallet first");
this.changePassword = new CheckBox("Change password");
final DialogPane dialogPane = getDialogPane();
setTitle("Wallet Password" + (walletName != null ? " - " + walletName : ""));
dialogPane.setHeaderText(walletName != null ? requirement.description.substring(0, requirement.description.length() - 1) + " for " + walletName + ":" : requirement.description);
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(260);
@@ -54,6 +57,10 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
backupExisting.setSelected(true);
}
if(requirement == PasswordRequirement.UPDATE_SET) {
content.getChildren().add(changePassword);
}
dialogPane.setContent(content);
ValidationSupport validationSupport = new ValidationSupport();
@@ -69,12 +76,12 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> passwordConfirm.isVisible() && !password.getText().equals(passwordConfirm.getText()), password.textProperty(), passwordConfirm.textProperty());
okButton.disableProperty().bind(isInvalid);
if(requirement != PasswordRequirement.UPDATE_NEW) {
if(requirement != PasswordRequirement.UPDATE_NEW && requirement != PasswordRequirement.UPDATE_CHANGE) {
passwordConfirm.setVisible(false);
passwordConfirm.setManaged(false);
}
if(requirement == PasswordRequirement.UPDATE_NEW || requirement == PasswordRequirement.UPDATE_EMPTY) {
if(requirement == PasswordRequirement.UPDATE_NEW || requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_CHANGE) {
password.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue.isEmpty()) {
okButton.setText("No Password");
@@ -99,11 +106,16 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
return backupExisting.isSelected();
}
public boolean isChangePassword() {
return changePassword.isSelected();
}
public enum PasswordRequirement {
LOAD("Please enter the wallet password:", "Unlock"),
UPDATE_NEW("Add a password to the wallet?\nLeave empty for none:", "No Password"),
UPDATE_EMPTY("This wallet has no password.\nAdd a password to the wallet?\nLeave empty for none:", "No Password"),
UPDATE_SET("Please re-enter the wallet password:", "Verify Password");
UPDATE_SET("Please re-enter the wallet password:", "Verify Password"),
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for none:", "No Password");
private final String description;
private final String okButtonText;
@@ -4,8 +4,12 @@ import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class WebcamView {
private static final Logger log = LoggerFactory.getLogger(WebcamView.class);
private final ImageView imageView;
private final WebcamService service;
private final Region view;
@@ -47,7 +51,7 @@ public class WebcamView {
imageView.imageProperty().unbind();
statusPlaceholder.setText("Error");
getChildren().setAll(statusPlaceholder);
service.getException().printStackTrace();
log.error("Failed to start web cam", service.getException());
break;
case SUCCEEDED:
// unreachable...
@@ -28,8 +28,9 @@ public class WelcomeDialog extends Dialog<Mode> {
setTitle("Welcome to Sparrow");
dialogPane.setHeaderText("Welcome to Sparrow!");
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(450);
dialogPane.setPrefHeight(480);
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if (!image.isError()) {
@@ -70,7 +71,8 @@ public class WelcomeDialog extends Dialog<Mode> {
}
private HyperlinkLabel createBulletedLink(String name, String url) {
HyperlinkLabel label = new HyperlinkLabel(" \u2022 [" + name + "]");
String[] nameParts = name.split(" ");
HyperlinkLabel label = new HyperlinkLabel(" \u2022 [" + nameParts[0] + "] " + (nameParts.length > 1 ? nameParts[1] : ""));
label.setOnAction(event -> {
hostServices.showDocument(url);
});
@@ -88,6 +90,8 @@ public class WelcomeDialog extends Dialog<Mode> {
private ToggleSwitch createToggle(StatusBar statusBar, ButtonType onlineButtonType, ButtonType offlineButtonType) {
ToggleSwitch toggleSwitch = new UnlabeledToggleSwitch();
toggleSwitch.setStyle("-fx-padding: 1px 0 0 0");
toggleSwitch.selectedProperty().addListener((observable, oldValue, newValue) -> {
Button onlineButton = (Button) getDialogPane().lookupButton(onlineButtonType);
onlineButton.setDefaultButton(newValue);
@@ -10,13 +10,15 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
private final String serverBanner;
private final int blockHeight;
private final BlockHeader blockHeader;
private final Double minimumRelayFeeRate;
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates) {
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Double minimumRelayFeeRate) {
super(targetBlockFeeRates);
this.serverVersion = serverVersion;
this.serverBanner = serverBanner;
this.blockHeight = blockHeight;
this.blockHeader = blockHeader;
this.minimumRelayFeeRate = minimumRelayFeeRate;
}
public List<String> getServerVersion() {
@@ -34,4 +36,8 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
public BlockHeader getBlockHeader() {
return blockHeader;
}
public Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
public class ExcludeUtxoEvent {
private final WalletTransaction walletTransaction;
private final BlockTransactionHashIndex utxo;
public ExcludeUtxoEvent(WalletTransaction walletTransaction, BlockTransactionHashIndex utxo) {
this.walletTransaction = walletTransaction;
this.utxo = utxo;
}
public WalletTransaction getWalletTransaction() {
return walletTransaction;
}
public BlockTransactionHashIndex getUtxo() {
return utxo;
}
}
@@ -0,0 +1,25 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
/**
* This event is used by the DeviceSignMessageDialog to indicate that a USB device has signed a message
*
*/
public class MessageSignedEvent {
private final Wallet wallet;
private final String signature;
public MessageSignedEvent(Wallet wallet, String signature) {
this.wallet = wallet;
this.signature = signature;
}
public Wallet getWallet() {
return wallet;
}
public String getSignature() {
return signature;
}
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.event;
public class RequestConnectEvent {
//Empty event class used to request programmatic connection to the server
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.event;
public class RequestDisconnectEvent {
//Empty event class used to request programmatic disconnection from the server
}
@@ -20,6 +20,6 @@ public class SettingsChangedEvent {
}
public enum Type {
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB;
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class TorStatusEvent {
private final String status;
public TorStatusEvent(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.TransactionTabData;
import java.util.List;
public class TransactionTabsClosedEvent {
private final List<TransactionTabData> closedTransactionTabData;
public TransactionTabsClosedEvent(List<TransactionTabData> closedTransactionTabData) {
this.closedTransactionTabData = closedTransactionTabData;
}
public List<TransactionTabData> getClosedTransactionTabData() {
return closedTransactionTabData;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class VersionCheckStatusEvent {
private final boolean enabled;
public VersionCheckStatusEvent(boolean enabled) {
this.enabled = enabled;
}
public boolean isEnabled() {
return enabled;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class VersionUpdatedEvent {
private final String version;
public VersionUpdatedEvent(String version) {
this.version = version;
}
public String getVersion() {
return version;
}
}
@@ -0,0 +1,41 @@
package com.sparrowwallet.sparrow.event;
public class WalletHistoryStatusEvent {
private final boolean loaded;
private final String statusMessage;
private final String errorMessage;
public WalletHistoryStatusEvent(boolean loaded) {
this.loaded = loaded;
this.statusMessage = null;
this.errorMessage = null;
}
public WalletHistoryStatusEvent(boolean loaded, String statusMessage) {
this.loaded = false;
this.statusMessage = statusMessage;
this.errorMessage = null;
}
public WalletHistoryStatusEvent(String errorMessage) {
this.loaded = false;
this.statusMessage = null;
this.errorMessage = errorMessage;
}
public boolean isLoading() {
return !loaded;
}
public boolean isLoaded() {
return loaded;
}
public String getStatusMessage() {
return statusMessage;
}
public String getErrorMessage() {
return errorMessage;
}
}
@@ -40,4 +40,8 @@ public class WalletNodeHistoryChangedEvent {
return null;
}
public String getScriptHash() {
return scriptHash;
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.WalletTabData;
import java.util.List;
public class WalletTabsClosedEvent {
private final List<WalletTabData> closedWalletTabData;
public WalletTabsClosedEvent(List<WalletTabData> closedWalletTabData) {
this.closedWalletTabData = closedWalletTabData;
}
public List<WalletTabData> getClosedWalletTabData() {
return closedWalletTabData;
}
}
@@ -15,6 +15,7 @@ public class FontAwesome5 extends GlyphFont {
* The individual glyphs offered by the FontAwesome5 font.
*/
public static enum Glyph implements INamedCharacter {
ANGLE_DOUBLE_RIGHT('\uf101'),
ARROW_DOWN('\uf063'),
ARROW_UP('\uf062'),
BTC('\uf15a'),
@@ -22,7 +23,10 @@ public class FontAwesome5 extends GlyphFont {
CHECK_CIRCLE('\uf058'),
CIRCLE('\uf111'),
COINS('\uf51e'),
EXCHANGE_ALT('\uf362'),
EXCLAMATION_CIRCLE('\uf06a'),
EXCLAMATION_TRIANGLE('\uf071'),
EXTERNAL_LINK_ALT('\uf35d'),
ELLIPSIS_H('\uf141'),
EYE('\uf06e'),
HAND_HOLDING('\uf4bd'),
@@ -33,10 +37,13 @@ public class FontAwesome5 extends GlyphFont {
PEN_FANCY('\uf5ac'),
QRCODE('\uf029'),
QUESTION_CIRCLE('\uf059'),
REPLY_ALL('\uf122'),
SATELLITE_DISH('\uf7c0'),
SD_CARD('\uf7c2'),
SEARCH('\uf002'),
TIMES_CIRCLE('\uf057'),
TOOLS('\uf7d9'),
UNDO('\uf0e2'),
WALLET('\uf555');
private final char ch;
@@ -21,7 +21,7 @@ public class Bip39 implements KeystoreMnemonicImport {
@Override
public String getKeystoreImportDescription() {
return "Import your 12 to 24 word mnemonic and optional passphrase";
return "Import or generate your 12 to 24 word mnemonic and optional passphrase.";
}
@Override
@@ -0,0 +1,55 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class CoboVaultMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Cobo Vault Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.COBO_VAULT;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Cobo Vault");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault.";
}
@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", "Cobo Vault"));
keystore.setWalletModel(WalletModel.COBO_VAULT);
}
return wallet;
}
@Override
public String getWalletImportDescription() {
return "Import file created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault.";
}
@Override
public String getWalletExportDescription() {
return "Export file that can be read by your Cobo Vault using the Multisig Wallet > Import Multisig Wallet feature.";
}
}
@@ -0,0 +1,33 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class CoboVaultSinglesig extends ColdcardSinglesig {
@Override
public String getName() {
return "Cobo Vault";
}
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.COBO_VAULT;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Cobo Vault");
keystore.setWalletModel(getWalletModel());
return keystore;
}
}
@@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.io.CharStreams;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
@@ -19,8 +18,6 @@ import java.util.List;
import java.util.Set;
public class ColdcardMultisig implements WalletImport, KeystoreFileImport, WalletExport {
private final Gson gson = new Gson();
@Override
public String getName() {
return "Coldcard Multisig";
@@ -68,7 +65,12 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB feature on your Coldcard";
return "Import file created by using the Settings > Multisig Wallets > Export XPUB feature on your Coldcard.";
}
@Override
public String getExportFileExtension() {
return "txt";
}
@Override
@@ -138,7 +140,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getWalletImportDescription() {
return "Import file created by using the Settings > Multisig Wallets > [Wallet Detail] > Coldcard Export feature on your Coldcard";
return "Import file created by using the Settings > Multisig Wallets > [Wallet Detail] > Coldcard Export feature on your Coldcard.";
}
@Override
@@ -148,7 +150,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
if(!wallet.getPolicyType().equals(PolicyType.MULTI)) {
throw new ExportException("Coldcard multisig import requires a multisig wallet");
throw new ExportException(getName() + " import requires a multisig wallet");
}
boolean multipleDerivations = false;
@@ -162,7 +164,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream));
writer.append("# Coldcard Multisig setup file (created by Sparrow)\n");
writer.append("# " + getName() + " setup file (created by Sparrow)\n");
writer.append("#\n");
writer.append("Name: ").append(wallet.getName()).append("\n");
writer.append("Policy: ").append(Integer.toString(wallet.getDefaultPolicy().getNumSignaturesRequired())).append(" of ").append(Integer.toString(wallet.getKeystores().size())).append("\n");
@@ -191,7 +193,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getWalletExportDescription() {
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from SD feature";
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from SD feature.";
}
@Override
@@ -5,9 +5,12 @@ import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.File;
@@ -16,7 +19,7 @@ import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.util.Map;
public class ColdcardSinglesig implements KeystoreFileImport {
public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
@Override
public String getName() {
return "Coldcard";
@@ -24,7 +27,7 @@ public class ColdcardSinglesig implements KeystoreFileImport {
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON feature on your Coldcard";
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
}
@Override
@@ -77,6 +80,29 @@ public class ColdcardSinglesig implements KeystoreFileImport {
throw new ImportException("Correct derivation not found for script type: " + scriptType);
}
@Override
public String getWalletImportDescription() {
return getKeystoreImportDescription();
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
//Use default of P2WPKH
Keystore keystore = getKeystore(ScriptType.P2WPKH, inputStream, "");
Wallet wallet = new Wallet();
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(ScriptType.P2WPKH);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
if(!wallet.isValid()) {
throw new ImportException("Wallet is in an inconsistent state.");
}
return wallet;
}
private static class ColdcardKeystore {
public String deriv;
public String name;
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,6 +24,8 @@ public class Config {
private boolean groupByAddress = true;
private boolean includeMempoolChange = true;
private boolean notifyNewTransactions = true;
private boolean checkNewVersions = true;
private Theme theme;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private File hwi;
@@ -41,7 +44,12 @@ public class Config {
}
private static File getConfigFile() {
return new File(Storage.getSparrowDir(), CONFIG_FILENAME);
File sparrowDir = Storage.getSparrowDir();
if(!sparrowDir.exists()) {
sparrowDir.mkdirs();
}
return new File(sparrowDir, CONFIG_FILENAME);
}
private static Config load() {
@@ -135,6 +143,24 @@ public class Config {
flush();
}
public boolean isCheckNewVersions() {
return checkNewVersions;
}
public void setCheckNewVersions(boolean checkNewVersions) {
this.checkNewVersions = checkNewVersions;
flush();
}
public Theme getTheme() {
return theme;
}
public void setTheme(Theme theme) {
this.theme = theme;
flush();
}
public List<File> getRecentWalletFiles() {
return recentWalletFiles;
}
@@ -9,15 +9,14 @@ import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.*;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.*;
import java.util.zip.InflaterInputStream;
public class Electrum implements KeystoreFileImport, WalletImport, WalletExport {
@@ -33,7 +32,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
@Override
public String getKeystoreImportDescription() {
return "Import a single keystore from an Electrum wallet (use File > Import > Electrum to import a multisig wallet)";
return "Import a single keystore from an Electrum wallet (use File > Import > Electrum to import a multisig wallet).";
}
@Override
@@ -46,7 +45,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
if(!wallet.getScriptType().equals(scriptType)) {
//TODO: Derive appropriate ScriptType keystore from xprv if present
throw new ImportException("Wallet has an incompatible script type of " + wallet.getScriptType() + ", and the correct script type cannot be derived without the master private key");
throw new ImportException("Wallet has an incompatible script type of " + wallet.getScriptType() + ", and the correct script type cannot be derived without the master private key.");
}
return wallet.getKeystores().get(0);
@@ -84,6 +83,38 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
ew.keystores.put(key, ek);
}
if(key.equals("labels")) {
JsonObject jsonObject = (JsonObject)map.get(key);
for(String labelKey : jsonObject.keySet()) {
ew.labels.put(labelKey, jsonObject.get(labelKey).getAsString());
}
}
if(key.equals("verified_tx3")) {
JsonObject jsonObject = (JsonObject)map.get(key);
for(String txKey : jsonObject.keySet()) {
Sha256Hash txHash = Sha256Hash.wrap(txKey);
JsonArray array = jsonObject.getAsJsonArray(txKey);
if(array != null && array.size() > 3) {
int height = array.get(0).getAsInt();
Date date = new Date(array.get(1).getAsLong() * 1000);
long fee = array.get(2).getAsLong();
Sha256Hash blockHash = Sha256Hash.wrap(array.get(3).getAsString());
JsonObject transactions = (JsonObject)map.get("transactions");
if(transactions != null) {
String txhex = transactions.get(txKey).getAsString();
if(txhex != null) {
Transaction transaction = new Transaction(Utils.hexToBytes(txhex));
BlockTransaction blockTransaction = new BlockTransaction(txHash, height, date, fee, transaction, blockHash);
ew.transactions.put(txHash, blockTransaction);
}
}
}
}
}
}
Wallet wallet = new Wallet();
@@ -105,11 +136,12 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
keystore.setSource(KeystoreSource.HW_USB);
keystore.setWalletModel(WalletModel.fromType(ek.hw_type));
if(keystore.getWalletModel() == null) {
throw new ImportException("Wallet has keystore of unknown hardware wallet type \"" + ek.hw_type + "\"");
throw new ImportException("Wallet has keystore of unknown hardware wallet type \"" + ek.hw_type + "\".");
}
} else if("bip32".equals(ek.type)) {
if(ek.xprv != null && ek.seed == null) {
throw new ImportException("Electrum does not support exporting BIP39 derived seeds.");
throw new ImportException("Electrum does not support exporting BIP39 derived seeds, as it does not store the mnemonic words. Only seeds created with its native Electrum Seed Version System are exportable. " +
"If you have the mnemonic words, create a new wallet with a BIP39 keystore.");
} else if(ek.seed != null) {
keystore.setSource(KeystoreSource.SW_SEED);
String mnemonic = ek.seed;
@@ -152,6 +184,20 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
throw new ImportException("Unknown Electrum wallet type of " + ew.wallet_type);
}
for(String key : ew.labels.keySet()) {
try {
Sha256Hash txHash = Sha256Hash.wrap(key);
BlockTransaction blockTransaction = ew.transactions.get(txHash);
if(blockTransaction != null) {
blockTransaction.setLabel(ew.labels.get(key));
}
} catch(IllegalArgumentException e) {
//not a tx, probably an address
}
}
wallet.updateTransactions(ew.transactions);
if(!wallet.isValid()) {
throw new IllegalStateException("Electrum wallet is in an inconsistent state.");
}
@@ -181,7 +227,12 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
@Override
public String getWalletImportDescription() {
return "Import an Electrum wallet";
return "Import an Electrum wallet.";
}
@Override
public String getExportFileExtension() {
return "json";
}
@Override
@@ -263,12 +314,12 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
@Override
public boolean isEncrypted(File file) {
return FileType.BINARY.equals(IOUtils.getFileType(file));
return (FileType.BINARY.equals(IOUtils.getFileType(file)));
}
@Override
public String getWalletExportDescription() {
return "Export this wallet as an Electrum wallet file";
return "Export this wallet as an Electrum wallet file.";
}
private static class ElectrumJsonWallet {
@@ -276,6 +327,8 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
public String wallet_type;
public String seed_type;
public Boolean use_encryption;
public Map<String, String> labels = new LinkedHashMap<>();
public Map<Sha256Hash, BlockTransaction> transactions = new HashMap<>();
}
public static class ElectrumKeystore {
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams;
import com.google.gson.*;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
@@ -23,7 +24,6 @@ import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -36,7 +36,7 @@ public class Hwi {
public List<Device> enumerate(String passphrase) throws ImportException {
try {
List<String> command;
if(passphrase != null) {
if(passphrase != null && !passphrase.isEmpty()) {
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), "--password", passphrase, Command.ENUMERATE.toString());
} else {
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), Command.ENUMERATE.toString());
@@ -44,7 +44,7 @@ public class Hwi {
String output = execute(command);
Device[] devices = getGson().fromJson(output, Device[].class);
return Arrays.asList(devices);
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
throw new ImportException(e);
}
@@ -63,6 +63,7 @@ public class Hwi {
public boolean sendPin(Device device, String pin) throws ImportException {
try {
String output = execute(getDeviceCommand(device, Command.SEND_PIN, pin));
isPromptActive = false;
return wasSuccessful(output);
} catch(IOException e) {
throw new ImportException(e);
@@ -72,12 +73,13 @@ public class Hwi {
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
try {
String output;
if(passphrase != null && device.getModel().equals(WalletModel.TREZOR_1)) {
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.GET_XPUB, derivationPath));
} else {
output = execute(getDeviceCommand(device, Command.GET_XPUB, derivationPath));
}
isPromptActive = false;
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
if(result.get("xpub") != null) {
return result.get("xpub").getAsString();
@@ -102,8 +104,9 @@ public class Hwi {
type = "--wpkh";
}
isPromptActive = false;
String output;
if(passphrase != null && device.getModel().equals(WalletModel.TREZOR_1)) {
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
} else {
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
@@ -113,23 +116,56 @@ public class Hwi {
if(result.get("address") != null) {
return result.get("address").getAsString();
} else {
throw new DisplayAddressException("Could not retrieve address");
JsonElement error = result.get("error");
if(error != null) {
throw new DisplayAddressException(error.getAsString());
} else {
throw new DisplayAddressException("Could not retrieve address");
}
}
} catch(IOException e) {
throw new DisplayAddressException(e);
}
}
public String signMessage(Device device, String passphrase, String message, String derivationPath) throws SignMessageException {
try {
isPromptActive = false;
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_MESSAGE, message, derivationPath));
} else {
output = execute(getDeviceCommand(device, Command.SIGN_MESSAGE, message, derivationPath));
}
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
if(result.get("signature") != null) {
return result.get("signature").getAsString();
} else {
JsonElement error = result.get("error");
if(error != null) {
throw new SignMessageException(error.getAsString());
} else {
throw new SignMessageException("Could not sign message");
}
}
} catch(IOException e) {
throw new SignMessageException(e);
}
}
public PSBT signPSBT(Device device, String passphrase, PSBT psbt) throws SignTransactionException {
try {
String psbtBase64 = psbt.toBase64String();
isPromptActive = true;
String output;
if(passphrase != null && device.getModel().equals(WalletModel.TREZOR_1)) {
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_TX, psbtBase64));
} else {
output = execute(getDeviceCommand(device, Command.SIGN_TX, psbtBase64));
}
isPromptActive = false;
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
if(result.get("psbt") != null) {
@@ -155,7 +191,6 @@ public class Hwi {
}
private String execute(List<String> command) throws IOException {
isPromptActive = false;
ProcessBuilder processBuilder = new ProcessBuilder(command);
Process process = processBuilder.start();
return CharStreams.toString(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
@@ -165,7 +200,7 @@ public class Hwi {
File hwiExecutable = Config.get().getHwi();
if(hwiExecutable != null && hwiExecutable.exists()) {
if(command.isTestFirst() && (!hwiExecutable.getAbsolutePath().contains(TEMP_FILE_PREFIX) || !testHwi(hwiExecutable))) {
if(Platform.getCurrent().getPlatformId().toLowerCase().equals("mac")) {
if(Platform.getCurrent() == Platform.OSX) {
deleteDirectory(hwiExecutable.getParentFile());
} else {
hwiExecutable.delete();
@@ -182,8 +217,8 @@ public class Hwi {
//To avoid doing these with every invocation, use a --onedir packaging and expand into a temp folder on OSX
//The check will still happen on first invocation, but will not thereafter
//See https://github.com/bitcoin-core/HWI/issues/327 for details
if(platform.getPlatformId().toLowerCase().equals("mac")) {
InputStream inputStream = Hwi.class.getResourceAsStream("/external/" + platform.getPlatformId().toLowerCase() + "/hwi-1.1.2-mac-amd64-signed.zip");
if(platform == Platform.OSX) {
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/hwi-1.1.2-mac-amd64-signed.zip");
Path tempHwiDirPath = Files.createTempDirectory(TEMP_FILE_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
File tempHwiDir = tempHwiDirPath.toFile();
//tempHwiDir.deleteOnExit();
@@ -211,12 +246,20 @@ public class Hwi {
hwiExecutable = tempExec;
} else {
InputStream inputStream = Hwi.class.getResourceAsStream("/external/" + platform.getPlatformId().toLowerCase() + "/hwi");
Path tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
InputStream inputStream;
Path tempExecPath;
if(platform == Platform.WINDOWS) {
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null);
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
}
File tempExec = tempExecPath.toFile();
//tempExec.deleteOnExit();
tempExec.deleteOnExit();
OutputStream tempExecStream = new BufferedOutputStream(new FileOutputStream(tempExec));
ByteStreams.copy(new GZIPInputStream(inputStream), tempExecStream);
ByteStreams.copy(inputStream, tempExecStream);
inputStream.close();
tempExecStream.flush();
tempExecStream.close();
@@ -280,17 +323,27 @@ public class Hwi {
}
private List<String> getDeviceCommand(Device device, Command command) throws IOException {
return List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString());
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--testnet");
}
return elements;
}
private List<String> getDeviceCommand(Device device, Command command, String... commandData) throws IOException {
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--testnet");
}
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
return elements;
}
private List<String> getDeviceCommand(Device device, String passphrase, Command command, String... commandData) throws IOException {
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), "--password", passphrase, command.toString()));
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--testnet");
}
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
return elements;
}
@@ -397,6 +450,30 @@ public class Hwi {
}
}
public static class SignMessageService extends Service<String> {
private final Device device;
private final String passphrase;
private final String message;
private final String derivationPath;
public SignMessageService(Device device, String passphrase, String message, String derivationPath) {
this.device = device;
this.passphrase = passphrase;
this.message = message;
this.derivationPath = derivationPath;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
protected String call() throws SignMessageException {
Hwi hwi = new Hwi();
return hwi.signMessage(device, passphrase, message, derivationPath);
}
};
}
}
public static class GetXpubService extends Service<String> {
private final Device device;
private final String passphrase;
@@ -467,6 +544,7 @@ public class Hwi {
PROMPT_PIN("promptpin", true),
SEND_PIN("sendpin", false),
DISPLAY_ADDRESS("displayaddress", true),
SIGN_MESSAGE("signmessage", true),
GET_XPUB("getxpub", true),
SIGN_TX("signtx", true);
@@ -7,7 +7,7 @@ public class IOUtils {
public static FileType getFileType(File file) {
try {
String type = Files.probeContentType(file.toPath());
if (type == null) {
if(type == null) {
if(file.getName().toLowerCase().endsWith("txn") || file.getName().toLowerCase().endsWith("psbt")) {
return FileType.TEXT;
}
@@ -17,6 +17,8 @@ public class IOUtils {
String line = br.readLine();
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
}
}
}
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.io;
public class SignMessageException extends Exception {
public SignMessageException() {
super();
}
public SignMessageException(String message) {
super(message);
}
public SignMessageException(Throwable cause) {
super(cause);
}
public SignMessageException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,93 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class Specter implements WalletImport, WalletExport {
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
SpecterWallet specterWallet = new SpecterWallet();
specterWallet.label = wallet.getName();
specterWallet.blockheight = wallet.getStoredBlockHeight();
specterWallet.descriptor = OutputDescriptor.getOutputDescriptor(wallet).toString(true);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
String json = gson.toJson(specterWallet);
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
} catch(Exception e) {
throw new ExportException(e);
}
}
@Override
public String getWalletExportDescription() {
return "Export a Specter wallet that can be read by Specter Desktop using Add new wallet > Import from wallet software";
}
@Override
public String getExportFileExtension() {
return "json";
}
@Override
public String getWalletImportDescription() {
return "Import a Specter wallet created by using the Wallets > Settings > Export to Wallet Software in Specter Desktop";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
Gson gson = new Gson();
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream), SpecterWallet.class);
if(specterWallet.descriptor != null) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(specterWallet.descriptor);
Wallet wallet = outputDescriptor.toWallet();
wallet.setName(specterWallet.label);
if(!wallet.isValid()) {
throw new ImportException("Specter wallet file did not contain a valid wallet");
}
return wallet;
}
} catch(Exception e) {
throw new ImportException(e);
}
throw new ImportException("File was not a valid Specter wallet");
}
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public String getName() {
return "Specter";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SPECTER;
}
public static class SpecterWallet {
public String label;
public Integer blockheight;
public String descriptor;
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.common.io.Files;
import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.*;
@@ -12,8 +13,10 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.MainApp;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.controlsfx.tools.Platform;
import java.io.*;
import java.lang.reflect.Type;
@@ -33,6 +36,7 @@ public class Storage {
private static final DateFormat BACKUP_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss");
public static final String SPARROW_DIR = ".sparrow";
public static final String WINDOWS_SPARROW_DIR = "Sparrow";
public static final String WALLETS_DIR = "wallets";
public static final String WALLETS_BACKUP_DIR = "backup";
public static final String HEADER_MAGIC_1 = "SPRW1";
@@ -77,15 +81,16 @@ public class Storage {
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
public Wallet loadWallet() throws IOException, MnemonicException {
public Wallet loadWallet() throws IOException {
Reader reader = new FileReader(walletFile);
Wallet wallet = gson.fromJson(reader, Wallet.class);
reader.close();
encryptionPubKey = NO_PASSWORD_KEY;
return wallet;
}
public WalletAndKey loadWallet(CharSequence password) throws IOException, MnemonicException, StorageException {
public WalletAndKey loadWallet(CharSequence password) throws IOException, StorageException {
InputStream fileStream = new FileInputStream(walletFile);
ECKey encryptionKey = getEncryptionKey(password, fileStream);
@@ -217,12 +222,16 @@ public class Storage {
if(read != BINARY_HEADER_LENGTH) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
byte[] decodedHeader = Base64.getDecoder().decode(header);
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
throw new StorageException("Not a Sparrow wallet - invalid magic");
try {
byte[] decodedHeader = Base64.getDecoder().decode(header);
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
throw new StorageException("Not a Sparrow wallet - invalid magic");
}
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
} catch(IllegalArgumentException e) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
} else {
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
@@ -269,10 +278,30 @@ public class Storage {
}
static File getSparrowDir() {
if(Network.get() != Network.MAINNET) {
return new File(getSparrowHome(), Network.get().getName());
}
return getSparrowHome();
}
static File getSparrowHome() {
if(System.getProperty(MainApp.APP_HOME_PROPERTY) != null) {
return new File(System.getProperty(MainApp.APP_HOME_PROPERTY));
}
if(Platform.getCurrent() == Platform.WINDOWS) {
return new File(getHomeDir(), WINDOWS_SPARROW_DIR);
}
return new File(getHomeDir(), SPARROW_DIR);
}
static File getHomeDir() {
if(Platform.getCurrent() == Platform.WINDOWS) {
return new File(System.getenv("APPDATA"));
}
return new File(System.getProperty("user.home"));
}
@@ -436,11 +465,9 @@ public class Storage {
protected Task<WalletAndKey> createTask() {
return new Task<>() {
protected WalletAndKey call() throws IOException, StorageException, MnemonicException {
try {
return storage.loadWallet(password);
} finally {
password.clear();
}
WalletAndKey walletAndKey = storage.loadWallet(password);
password.clear();
return walletAndKey;
}
};
}
@@ -7,4 +7,5 @@ import java.io.OutputStream;
public interface WalletExport extends Export {
void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException;
String getWalletExportDescription();
String getExportFileExtension();
}
@@ -16,9 +16,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
public void initializeView() {
List<KeystoreFileImport> importers = Collections.emptyList();
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
importers = List.of(new ColdcardSinglesig());
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig());
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
importers = List.of(new ColdcardMultisig());
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig());
}
for(KeystoreImport importer : importers) {
@@ -1,17 +1,17 @@
package com.sparrowwallet.sparrow.keystoreimport;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.*;
import org.controlsfx.tools.Borders;
import java.io.IOException;
@@ -27,21 +27,27 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
final DialogPane dialogPane = getDialogPane();
AppController.setStageIcon(dialogPane.getScene().getWindow());
try {
FXMLLoader ksiLoader = new FXMLLoader(AppController.class.getResource("keystoreimport/keystoreimport.fxml"));
dialogPane.setContent(Borders.wrap(ksiLoader.load()).lineBorder().outerPadding(0).innerPadding(0).buildAll());
dialogPane.setContent(Borders.wrap(ksiLoader.load()).emptyBorder().buildAll());
keystoreImportController = ksiLoader.getController();
keystoreImportController.initializeView(wallet);
keystoreImportController.selectSource(initialSource);
final ButtonType watchOnlyButtonType = new javafx.scene.control.ButtonType(Network.get().getXpubHeader().getDisplayName() + " / Watch Only Wallet", ButtonBar.ButtonData.LEFT);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.getButtonTypes().addAll(watchOnlyButtonType, cancelButtonType);
dialogPane.setPrefWidth(650);
dialogPane.setPrefHeight(600);
dialogPane.setPrefHeight(620);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? keystore : null);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? getWatchOnlyKeystore() : null);
} catch(IOException e) {
throw new RuntimeException(e);
}
@@ -51,10 +57,17 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
return List.of(KeystoreSource.HW_USB, KeystoreSource.HW_AIRGAPPED, KeystoreSource.SW_SEED);
}
private Keystore getWatchOnlyKeystore() {
this.keystore = new Keystore();
keystore.setSource(KeystoreSource.SW_WATCH);
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setKeyDerivation(new KeyDerivation("",""));
return keystore;
}
@Subscribe
public void keystoreImported(KeystoreImportEvent event) {
this.keystore = event.getKeystore();
setResult(keystore);
this.close();
}
}
@@ -6,21 +6,26 @@ import com.github.arteam.simplejsonrpc.client.builder.BatchRequestBuilder;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
public class BatchedElectrumServerRpc implements ElectrumServerRpc {
private static final Logger log = LoggerFactory.getLogger(BatchedElectrumServerRpc.class);
private final AtomicLong idCounter = new AtomicLong();
@Override
public void ping(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
client.createRequest().method("server.ping").id(1).executeNullable();
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error pinging server", e);
}
@@ -30,7 +35,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAsList(String.class).method("server.version").id(1).param("client_name", clientName).param("protocol_version", supportedVersions).execute();
return client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).param("client_name", clientName).param("protocol_version", supportedVersions).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error getting server version", e);
}
@@ -40,7 +45,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public String getServerBanner(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(String.class).method("server.banner").id(1).execute();
return client.createRequest().returnAs(String.class).method("server.banner").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error getting server banner", e);
}
@@ -50,7 +55,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public BlockHeaderTip subscribeBlockHeaders(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(1).execute();
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error subscribing to block headers", e);
}
@@ -61,6 +66,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Loading transactions"));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.get_history", pathScriptHashes.get(path));
@@ -112,6 +118,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<String, String> subscribeScriptHashes(Transport transport, Map<String, String> pathScriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Finding transactions"));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.subscribe", pathScriptHashes.get(path));
@@ -130,6 +137,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<Integer, String> getBlockHeaders(Transport transport, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<Integer, String> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving blocks"));
for(Integer height : blockHeights) {
batchRequest.add(height, "blockchain.block.header", height);
}
@@ -146,6 +155,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<String, String> getTransactions(Transport transport, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving transactions"));
for(String txid : txids) {
batchRequest.add(txid, "blockchain.transaction.get", txid);
}
@@ -167,7 +178,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids) {
public Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, VerboseTransaction> batchRequest = client.createBatchRequest().keysType(String.class).returnType(VerboseTransaction.class);
for(String txid : txids) {
@@ -197,11 +208,21 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
}
@Override
public Double getMinimumRelayFee(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(Double.class).method("blockchain.relayfee").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error getting minimum relay fee", e);
}
}
@Override
public String broadcastTransaction(Transport transport, String txHex) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(1).param("raw_tx", txHex).execute();
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).param("raw_tx", txHex).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException(e.getErrorMessage().getMessage(), e);
}
@@ -1,6 +1,8 @@
package com.sparrowwallet.sparrow.net;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.google.common.collect.Iterables;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
@@ -8,8 +10,11 @@ import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.event.ConnectionEvent;
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.SendController;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@@ -29,7 +34,7 @@ public class ElectrumServer {
private static Transport transport;
private static final Map<String, String> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static final Map<String, Set<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
@@ -130,43 +135,52 @@ public class ElectrumServer {
return receiveTransactionMap;
}
public Map<WalletNode, Set<BlockTransactionHash>> getHistory(Wallet wallet, Collection<WalletNode> nodes) throws ServerException {
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
subscribeWalletNodes(wallet, nodes, nodeTransactionMap, 0);
getReferences(wallet, nodes, nodeTransactionMap);
return nodeTransactionMap;
}
public void getHistory(Wallet wallet, KeyPurpose keyPurpose, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
WalletNode purposeNode = wallet.getNode(keyPurpose);
getHistory(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
//Because node children are added sequentially in WalletNode.fillToIndex, we can simply look at the number of children to determine the highest filled index
int historySize = purposeNode.getChildren().size();
//The gap limit size takes the highest used index in the retrieved history and adds the gap limit (plus one to be comparable to the number of children since index is zero based)
int gapLimitSize = getGapLimitSize(nodeTransactionMap);
int gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
while(historySize < gapLimitSize) {
purposeNode.fillToIndex(gapLimitSize - 1);
getHistory(wallet, purposeNode.getChildren(), nodeTransactionMap, historySize);
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, historySize);
historySize = purposeNode.getChildren().size();
gapLimitSize = getGapLimitSize(nodeTransactionMap);
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
}
//All WalletNode keys in nodeTransactionMap with non-null values need to have their history fetched
Collection<WalletNode> usedNodes = nodeTransactionMap.entrySet().stream().filter(entry -> entry.getValue() != null).map(Map.Entry::getKey).collect(Collectors.toList());
log.debug("Retrieving history for " + usedNodes.stream().map(WalletNode::getDerivationPath).collect(Collectors.joining(", ")));
getReferences(wallet, usedNodes, nodeTransactionMap);
//Set the remaining WalletNode keys to empty sets to indicate no history
nodeTransactionMap.entrySet().stream().filter(entry -> entry.getValue() == null).forEach(entry -> entry.setValue(Collections.emptySet()));
}
private int getGapLimitSize(Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
int highestIndex = nodeTransactionMap.entrySet().stream().filter(entry -> !entry.getValue().isEmpty()).map(entry -> entry.getKey().getIndex()).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
return highestIndex + Wallet.DEFAULT_LOOKAHEAD + 1;
return highestIndex + wallet.getGapLimit() + 1;
}
public void getHistory(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
getReferences(wallet, "blockchain.scripthash.get_history", nodes, nodeTransactionMap, startIndex);
subscribeWalletNodes(wallet, nodes, startIndex);
}
public void getMempool(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
getReferences(wallet, "blockchain.scripthash.get_mempool", nodes, nodeTransactionMap, startIndex);
}
public void getReferences(Wallet wallet, String method, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
try {
Map<String, String> pathScriptHashes = new LinkedHashMap<>(nodes.size());
for(WalletNode node : nodes) {
if(node.getIndex() >= startIndex) {
pathScriptHashes.put(node.getDerivationPath(), getScriptHash(wallet, node));
}
pathScriptHashes.put(node.getDerivationPath(), getScriptHash(wallet, node));
}
if(pathScriptHashes.isEmpty()) {
return;
}
//Even if we have some successes, failure to retrieve all references will result in an incomplete wallet history. Don't proceed if that's the case.
@@ -207,14 +221,18 @@ public class ElectrumServer {
}
}
public void subscribeWalletNodes(Wallet wallet, Collection<WalletNode> nodes, int startIndex) throws ServerException {
public void subscribeWalletNodes(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
try {
Set<String> scriptHashes = new HashSet<>();
Map<String, String> pathScriptHashes = new LinkedHashMap<>();
for(WalletNode node : nodes) {
if(node.getIndex() >= startIndex) {
String scriptHash = getScriptHash(wallet, node);
if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
if(getSubscribedScriptHashStatus(scriptHash) != null) {
//Already subscribed, but still need to fetch history from a used node
nodeTransactionMap.put(node, new TreeSet<>());
} else if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
//Unique script hash we are not yet subscribed to
pathScriptHashes.put(node.getDerivationPath(), scriptHash);
}
}
@@ -232,7 +250,15 @@ public class ElectrumServer {
Optional<WalletNode> optionalNode = nodes.stream().filter(n -> n.getDerivationPath().equals(path)).findFirst();
if(optionalNode.isPresent()) {
WalletNode node = optionalNode.get();
subscribedScriptHashes.put(getScriptHash(wallet, node), status);
String scriptHash = getScriptHash(wallet, node);
//Check if there is history for this script hash
if(status != null) {
//Set the value for this node to be an empty set to mark it as requiring a get_history RPC call for this wallet
nodeTransactionMap.put(node, new TreeSet<>());
}
updateSubscribedScriptHashStatus(scriptHash, status);
}
}
} catch (ElectrumServerRpcException e) {
@@ -370,7 +396,7 @@ public class ElectrumServer {
}
BlockTransactionHash reference = optionalReference.get();
Date blockDate;
Date blockDate = null;
if(reference.getHeight() > 0) {
BlockHeader blockHeader = blockHeaderMap.get(reference.getHeight());
if(blockHeader == null) {
@@ -379,8 +405,6 @@ public class ElectrumServer {
continue;
}
blockDate = blockHeader.getTimeAsDate();
} else {
blockDate = new Date();
}
BlockTransaction blockchainTransaction = new BlockTransaction(reference.getHash(), reference.getHeight(), blockDate, reference.getFee(), transaction);
@@ -479,13 +503,13 @@ public class ElectrumServer {
}
}
public Map<Sha256Hash, BlockTransaction> getReferencedTransactions(Set<Sha256Hash> references) throws ServerException {
public Map<Sha256Hash, BlockTransaction> getReferencedTransactions(Set<Sha256Hash> references, String scriptHash) throws ServerException {
Set<String> txids = new LinkedHashSet<>(references.size());
for(Sha256Hash reference : references) {
txids.add(reference.toString());
}
Map<String, VerboseTransaction> result = electrumServerRpc.getVerboseTransactions(getTransport(), txids);
Map<String, VerboseTransaction> result = electrumServerRpc.getVerboseTransactions(getTransport(), txids, scriptHash);
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
for(String txid : result.keySet()) {
@@ -503,7 +527,8 @@ public class ElectrumServer {
Map<Integer, Double> targetBlocksFeeRatesSats = new TreeMap<>();
for(Integer target : targetBlocksFeeRatesBtcKb.keySet()) {
targetBlocksFeeRatesSats.put(target, targetBlocksFeeRatesBtcKb.get(target) * Transaction.SATOSHIS_PER_BITCOIN / 1024);
long minFeeRateSatsKb = (long)(targetBlocksFeeRatesBtcKb.get(target) * Transaction.SATOSHIS_PER_BITCOIN);
targetBlocksFeeRatesSats.put(target, minFeeRateSatsKb / 1000d);
}
return targetBlocksFeeRatesSats;
@@ -512,6 +537,16 @@ public class ElectrumServer {
}
}
public Double getMinimumRelayFee() throws ServerException {
Double minFeeRateBtcKb = electrumServerRpc.getMinimumRelayFee(getTransport());
if(minFeeRateBtcKb != null) {
long minFeeRateSatsKb = (long)(minFeeRateBtcKb * Transaction.SATOSHIS_PER_BITCOIN);
return minFeeRateSatsKb / 1000d;
}
return Transaction.DEFAULT_MIN_RELAY_FEE;
}
public Sha256Hash broadcastTransaction(Transaction transaction) throws ServerException {
byte[] rawtxBytes = transaction.bitcoinSerialize();
String rawtxHex = Utils.bytesToHex(rawtxBytes);
@@ -535,16 +570,30 @@ public class ElectrumServer {
return Utils.bytesToHex(reversed);
}
private String getScriptHash(TransactionOutput output) {
public static String getScriptHash(TransactionOutput output) {
byte[] hash = Sha256Hash.hash(output.getScript().getProgram());
byte[] reversed = Utils.reverseBytes(hash);
return Utils.bytesToHex(reversed);
}
static Map<String, String> getSubscribedScriptHashes() {
public static Map<String, Set<String>> getSubscribedScriptHashes() {
return subscribedScriptHashes;
}
public static String getSubscribedScriptHashStatus(String scriptHash) {
Set<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
if(existingStatuses != null) {
return Iterables.getLast(existingStatuses);
}
return null;
}
public static void updateSubscribedScriptHashStatus(String scriptHash, String status) {
Set<String> existingStatuses = subscribedScriptHashes.computeIfAbsent(scriptHash, k -> new LinkedHashSet<>());
existingStatuses.add(status);
}
public static boolean supportsBatching(List<String> serverVersion) {
return serverVersion.size() > 0 && serverVersion.get(0).toLowerCase().contains("electrumx");
}
@@ -579,8 +628,8 @@ public class ElectrumServer {
private final boolean subscribe;
private boolean firstCall = true;
private Thread reader;
private Throwable lastReaderException;
private long feeRatesRetrievedAt;
private StringProperty statusProperty = new SimpleStringProperty();
public ConnectionService() {
this(true);
@@ -598,11 +647,14 @@ public class ElectrumServer {
if(firstCall) {
electrumServer.connect();
reader = new Thread(new ReadRunnable());
reader = new Thread(new ReadRunnable(), "ElectrumServerReadThread");
reader.setDaemon(true);
reader.setUncaughtExceptionHandler(ConnectionService.this);
reader.start();
//Start with simple RPC for maximum compatibility
electrumServerRpc = new SimpleElectrumServerRpc();
List<String> serverVersion = electrumServer.getServerVersion();
firstCall = false;
@@ -610,8 +662,6 @@ public class ElectrumServer {
if(supportsBatching(serverVersion)) {
log.debug("Upgrading to batched JSON-RPC");
electrumServerRpc = new BatchedElectrumServerRpc();
} else {
electrumServerRpc = new SimpleElectrumServerRpc();
}
BlockHeaderTip tip;
@@ -627,7 +677,12 @@ public class ElectrumServer {
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
feeRatesRetrievedAt = System.currentTimeMillis();
return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates);
Double minimumRelayFeeRate = electrumServer.getMinimumRelayFee();
for(Integer blockTarget : blockTargetFeeRates.keySet()) {
blockTargetFeeRates.computeIfPresent(blockTarget, (blocks, feeRate) -> feeRate < minimumRelayFeeRate ? minimumRelayFeeRate : feeRate);
}
return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates, minimumRelayFeeRate);
} else {
if(reader.isAlive()) {
electrumServer.ping();
@@ -639,8 +694,7 @@ public class ElectrumServer {
return new FeeRatesUpdatedEvent(blockTargetFeeRates);
}
} else {
firstCall = true;
throw new ServerException("Connection to server failed", lastReaderException);
resetConnection();
}
}
@@ -649,12 +703,21 @@ public class ElectrumServer {
};
}
public void resetConnection() {
try {
closeActiveConnection();
firstCall = true;
} catch (ServerException e) {
log.error("Error closing connection during connection reset", e);
}
}
@Override
public boolean cancel() {
try {
closeActiveConnection();
} catch (ServerException e) {
log.error("Eror closing connection", e);
log.error("Error closing connection", e);
}
return super.cancel();
@@ -664,12 +727,20 @@ public class ElectrumServer {
public void reset() {
super.reset();
firstCall = true;
lastReaderException = null;
}
@Override
public void uncaughtException(Thread t, Throwable e) {
this.lastReaderException = e;
log.error("Uncaught error in ConnectionService", e);
}
@Subscribe
public void torStatus(TorStatusEvent event) {
statusProperty.set(event.getStatus());
}
public StringProperty statusProperty() {
return statusProperty;
}
}
@@ -679,17 +750,25 @@ public class ElectrumServer {
try {
TcpTransport tcpTransport = (TcpTransport)getTransport();
tcpTransport.readInputLoop();
} catch (ServerException e) {
throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
} catch(ServerException e) {
//Only debug logging here as the exception has been passed on to the ConnectionService thread via TcpTransport
log.debug("Read thread terminated", e);
}
}
}
public static class TransactionHistoryService extends Service<Boolean> {
private final Wallet wallet;
private final Set<WalletNode> nodes;
public TransactionHistoryService(Wallet wallet) {
this.wallet = wallet;
this.nodes = null;
}
public TransactionHistoryService(Wallet wallet, Set<WalletNode> nodes) {
this.wallet = wallet;
this.nodes = nodes;
}
@Override
@@ -697,7 +776,7 @@ public class ElectrumServer {
return new Task<>() {
protected Boolean call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = electrumServer.getHistory(wallet);
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
return true;
@@ -708,6 +787,7 @@ public class ElectrumServer {
public static class TransactionReferenceService extends Service<Map<Sha256Hash, BlockTransaction>> {
private final Set<Sha256Hash> references;
private String scriptHash;
public TransactionReferenceService(Transaction transaction) {
references = new HashSet<>();
@@ -717,6 +797,11 @@ public class ElectrumServer {
}
}
public TransactionReferenceService(Set<Sha256Hash> references, String scriptHash) {
this(references);
this.scriptHash = scriptHash;
}
public TransactionReferenceService(Set<Sha256Hash> references) {
this.references = references;
}
@@ -726,7 +811,7 @@ public class ElectrumServer {
return new Task<>() {
protected Map<Sha256Hash, BlockTransaction> call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
return electrumServer.getReferencedTransactions(references);
return electrumServer.getReferencedTransactions(references, scriptHash);
}
};
}
@@ -25,9 +25,11 @@ public interface ElectrumServerRpc {
Map<String, String> getTransactions(Transport transport, Set<String> txids);
Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids);
Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash);
Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks);
Double getMinimumRelayFee(Transport transport);
String broadcastTransaction(Transport transport, String txHex);
}
@@ -14,12 +14,16 @@ public enum Protocol {
TCP {
@Override
public Transport getTransport(HostAndPort server) {
if(isOnionAddress(server)) {
return new TorTcpTransport(server);
}
return new TcpTransport(server);
}
@Override
public Transport getTransport(HostAndPort server, File serverCert) {
return new TcpTransport(server);
return getTransport(server);
}
@Override
@@ -35,11 +39,19 @@ public enum Protocol {
SSL {
@Override
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
if(isOnionAddress(server)) {
return new TorTcpOverTlsTransport(server);
}
return new TcpOverTlsTransport(server);
}
@Override
public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
if(isOnionAddress(server)) {
return new TorTcpOverTlsTransport(server, serverCert);
}
return new TcpOverTlsTransport(server, serverCert);
}
@@ -82,6 +94,10 @@ public enum Protocol {
return toUrlString() + hostAndPort.toString();
}
public boolean isOnionAddress(HostAndPort server) {
return server.getHost().toLowerCase().endsWith(".onion");
}
public static Protocol getProtocol(String url) {
if(url.startsWith("tcp://")) {
return TCP;
@@ -1,28 +1,31 @@
package com.sparrowwallet.sparrow.net;
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.sparrowwallet.drongo.protocol.Transaction.DUST_RELAY_TX_FEE;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
public class SimpleElectrumServerRpc implements ElectrumServerRpc {
private static final Logger log = LoggerFactory.getLogger(SimpleElectrumServerRpc.class);
private static final int MAX_TARGET_BLOCKS = 25;
private final AtomicLong idCounter = new AtomicLong();
@Override
public void ping(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
client.createRequest().method("server.ping").id(1).executeNullable();
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error pinging server", e);
}
@@ -33,7 +36,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
try {
JsonRpcClient client = new JsonRpcClient(transport);
//Using 1.4 as the version number as EPS tries to parse this number to a float
return client.createRequest().returnAsList(String.class).method("server.version").id(1).params(clientName, "1.4").execute();
return client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error getting server version", e);
}
@@ -43,7 +46,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
public String getServerBanner(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(String.class).method("server.banner").id(1).execute();
return client.createRequest().returnAs(String.class).method("server.banner").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error getting server banner", e);
}
@@ -53,7 +56,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
public BlockHeaderTip subscribeBlockHeaders(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(1).execute();
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error subscribing to block headers", e);
}
@@ -65,8 +68,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Loading transactions for " + path));
try {
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path).params(pathScriptHashes.get(path)).execute();
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute();
result.put(path, scriptHashTxes);
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
if(failOnError) {
@@ -87,7 +91,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
try {
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(path).params(pathScriptHashes.get(path)).execute();
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute();
result.put(path, scriptHashTxes);
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
if(failOnError) {
@@ -107,9 +111,10 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, String> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Finding transactions for " + path));
try {
client.createRequest().method("blockchain.scripthash.subscribe").id(path).params(pathScriptHashes.get(path)).executeNullable();
result.put(path, "");
String scriptHash = client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable();
result.put(path, scriptHash);
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e);
@@ -125,8 +130,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<Integer, String> result = new LinkedHashMap<>();
for(Integer blockHeight : blockHeights) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving block at height " + blockHeight));
try {
String blockHeader = client.createRequest().returnAs(String.class).method("blockchain.block.header").id(blockHeight).params(blockHeight).execute();
String blockHeader = client.createRequest().returnAs(String.class).method("blockchain.block.header").id(idCounter.incrementAndGet()).params(blockHeight).execute();
result.put(blockHeight, blockHeader);
} catch(IllegalStateException | IllegalArgumentException e) {
log.warn("Failed to retrieve block header for block height: " + blockHeight + " (" + e.getMessage() + ")");
@@ -144,8 +150,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, String> result = new LinkedHashMap<>();
for(String txid : txids) {
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving transaction [" + txid.substring(0, 6) + "]"));
try {
String rawTxHex = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(txid).params(txid).execute();
String rawTxHex = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute();
result.put(txid, rawTxHex);
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
result.put(txid, Sha256Hash.ZERO_HASH.toString());
@@ -156,18 +163,45 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids) {
public Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, VerboseTransaction> result = new LinkedHashMap<>();
for(String txid : txids) {
try {
VerboseTransaction verboseTransaction = client.createRequest().returnAs(VerboseTransaction.class).method("blockchain.transaction.get").id(txid).params(txid, true).execute();
VerboseTransaction verboseTransaction = client.createRequest().returnAs(VerboseTransaction.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid, true).execute();
result.put(txid, verboseTransaction);
} catch(IllegalStateException | IllegalArgumentException e) {
log.warn("Error retrieving transaction: " + txid + " (" + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()) + ")");
} catch(JsonRpcException e) {
log.warn("Error retrieving transaction: " + txid + " (" + e.getErrorMessage() + ")");
} catch(Exception e) {
//electrs does not currently support the verbose parameter, so try to fetch an incomplete VerboseTransaction without it
//Note that without the script hash associated with the transaction, we can't get a block height as there is no way in the Electrum RPC protocol to do this
//We mark this VerboseTransaction as incomplete by assigning it a Sha256Hash.ZERO_HASH blockhash
log.debug("Error retrieving transaction: " + txid + " (" + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()) + ")");
try {
String rawTxHex = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute();
Transaction tx = new Transaction(Utils.hexToBytes(rawTxHex));
String id = tx.getTxId().toString();
int height = 0;
if(scriptHash != null) {
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(idCounter.incrementAndGet()).params(scriptHash).execute();
for(ScriptHashTx scriptHashTx : scriptHashTxes) {
if(scriptHashTx.tx_hash.equals(id)) {
height = scriptHashTx.height;
break;
}
}
}
VerboseTransaction verboseTransaction = new VerboseTransaction();
verboseTransaction.txid = id;
verboseTransaction.hex = rawTxHex;
verboseTransaction.confirmations = (height <= 0 ? 0 : AppController.getCurrentBlockHeight() - height + 1);
verboseTransaction.blockhash = Sha256Hash.ZERO_HASH.toString();
result.put(txid, verboseTransaction);
} catch(Exception ex) {
throw new ElectrumServerRpcException("Error retrieving transaction: ", ex);
}
}
}
@@ -180,25 +214,39 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<Integer, Double> result = new LinkedHashMap<>();
for(Integer targetBlock : targetBlocks) {
try {
Double targetBlocksFeeRateBtcKb = client.createRequest().returnAs(Double.class).method("blockchain.estimatefee").id(targetBlock).params(targetBlock).execute();
result.put(targetBlock, targetBlocksFeeRateBtcKb);
} catch(IllegalStateException | IllegalArgumentException e) {
log.warn("Failed to retrieve fee rate for target blocks: " + targetBlock + " (" + e.getMessage() + ")");
result.put(targetBlock, DUST_RELAY_TX_FEE);
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Failed to retrieve fee rate for target blocks: " + targetBlock, e);
if(targetBlock <= MAX_TARGET_BLOCKS) {
try {
Double targetBlocksFeeRateBtcKb = client.createRequest().returnAs(Double.class).method("blockchain.estimatefee").id(idCounter.incrementAndGet()).params(targetBlock).execute();
result.put(targetBlock, targetBlocksFeeRateBtcKb);
} catch(IllegalStateException | IllegalArgumentException e) {
log.warn("Failed to retrieve fee rate for target blocks: " + targetBlock + " (" + e.getMessage() + ")");
result.put(targetBlock, result.values().stream().mapToDouble(v -> v).min().orElse(0.0001d));
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Failed to retrieve fee rate for target blocks: " + targetBlock, e);
}
} else {
result.put(targetBlock, result.values().stream().mapToDouble(v -> v).min().orElse(0.0001d));
}
}
return result;
}
@Override
public Double getMinimumRelayFee(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(Double.class).method("blockchain.relayfee").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error getting minimum relay fee", e);
}
}
@Override
public String broadcastTransaction(Transport transport, String txHex) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(1).params(txHex).execute();
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).params(txHex).execute();
} catch(IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException(e.getMessage(), e);
} catch(JsonRpcException e) {

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