Compare commits

..
108 Commits
Author SHA1 Message Date
Craig Raw 67dcf69a78 v1.7.2 2023-02-09 13:18:30 +02:00
Craig Raw 7ad8a04bda improve legacy core wallet error messages 2023-02-09 13:14:03 +02:00
Craig Raw 24e75603c6 update categories for linux desktop installation 2023-02-09 10:53:45 +02:00
Craig Raw 545342dfb4 disable server toggle when no server is configured (url host is empty) 2023-02-09 10:29:19 +02:00
Craig Raw b15d6308bd write and parse both multipath and single descriptors in wallet output descriptor export 2023-02-09 09:06:30 +02:00
Craig Raw ff0c381437 cormorant: find cookie dir for non-mainnet networks 2023-02-08 14:10:20 +02:00
Craig Raw 555260e954 implement bip329 for importing and exporting wallet labels 2023-02-08 08:03:06 +02:00
Craig Raw 8d584d1c48 followup for aarch64 2023-02-07 09:13:32 +02:00
Craig Raw 967cf0cdfa try to locate pcsc library on linux before searching for card terminals 2023-02-06 16:44:34 +02:00
Craig Raw 41ba8455a0 cormorant: avoid importing wallets when testing connection, only show prune warning once per connection 2023-02-06 13:25:35 +02:00
Craig Raw d84f3bf887 add config property autoSwitchProxy to disable automatic proxy switching on failure, and improve tor connection failure message 2023-02-06 11:38:19 +02:00
Craig Raw 153815d9e3 indicate in ssl handshake warning that a certificate renewal may be the cause 2023-02-06 11:01:42 +02:00
Craig Raw 0250579445 tapsigner: change card backup to base64 2023-02-06 10:56:53 +02:00
Craig Raw 7590d786b5 fix copy address output script bytes to return entire scriptpubkey 2023-02-03 13:57:29 +02:00
Craig Raw 55809b7dc3 decrypt keystore before requesting passphrase to show masterfingerprint 2023-02-03 08:20:09 +02:00
Craig Raw 06026b0a09 further improvements on wallet importing wrt pruned nodes 2023-02-01 14:30:24 +02:00
Craig Raw 2cd64aa650 improve handling of scan dates earlier than core pruned date 2023-02-01 13:48:20 +02:00
Craig Raw 0b980f6ab5 satscard: retrieve private keys for previously used slots 2023-02-01 11:37:36 +02:00
Craig Raw 73dcef9fd1 followup: add or remove card option from pay to dropdown as reader becomes available 2023-02-01 10:07:59 +02:00
Craig Raw 4e3491ec64 tapsigner and satscard initialization fixes, satscard address and private key retrieval, core address scanning support 2023-02-01 09:39:49 +02:00
Craig Raw 176e440195 unseal satscard functionality added to sweep private key dialog 2023-01-31 09:30:53 +02:00
Craig Raw 300545b289 refactor cardapi to generic service 2023-01-30 14:47:00 +02:00
Craig Raw 057a9efb1f cormorant: fix initialisation of sent txes without txindex 2023-01-30 14:46:12 +02:00
Craig Raw 9edeff9aab cormorant: set wallet to load on bitcoind startup, check if loaded first 2023-01-30 12:28:07 +02:00
Craig Raw f938506a3f add tapsigner message signing support 2023-01-30 09:41:12 +02:00
Craig Raw 4fb8c5a61b add card scan to hwi enumeration and refactor device pane 2023-01-27 13:58:38 +02:00
Craig Raw 7a99c4a11a add tapsigner signing support and refactor card api 2023-01-27 10:39:29 +02:00
Craig Raw 6c13504644 implement card initialization functionality 2023-01-26 15:47:33 +02:00
Craig Raw 3ddf4ed4b2 add functionality for tapsigner backup and pin change 2023-01-26 13:00:25 +02:00
Craig Raw 6b59ff60ad initialize and import tapsigner as keystore 2023-01-25 14:19:22 +02:00
Craig Raw 7c64d689fd cormorant: threading and scan date initialization improvements 2023-01-20 12:56:25 +02:00
Craig Raw 4ad9cdedb6 add merge function for wallet transaction entry edge case 2023-01-19 14:12:35 +02:00
Craig Raw 276cb8aecb cormorant: support transaction.get without txindex, use step function to add bip47 addresses 2023-01-19 13:52:47 +02:00
Craig Raw e7ed82699c add jade multisig export to wallet export dialog 2023-01-18 13:25:39 +02:00
Craig Raw 68cd3673af upgrade to hwi 2.2.0, add support for entering empty passphrases to trezor one 2023-01-18 11:13:13 +02:00
Craig Raw 5f96570c07 request treetable focus after editing a label cell 2023-01-16 14:46:24 +02:00
Craig Raw 5147ee8aee prefer loading transaction inputs from existing wallet transactions 2023-01-16 14:13:15 +02:00
Craig Raw 3cc2981b72 followup 2023-01-11 14:14:31 +02:00
Craig Raw 8038298485 show lifehash for master fingerprint in settings and passphrase dialog 2023-01-11 14:01:41 +02:00
Craig Raw d1a1bd5751 fix persistence of renaming and deleting newly created wallet accounts 2023-01-09 09:57:35 +02:00
Craig Raw 63b7aef91e upgrade gradle to 7.6 2023-01-07 08:23:44 +02:00
Craig Raw 8a51a47156 upgrade gradle to 7.6 2023-01-06 10:49:26 +02:00
Craig Raw 827efe071e upgrade junit 2023-01-05 12:17:49 +02:00
Craig Raw 56784b684a allow expired certificates for electrum servers so long as they have been previously used or explicitly approved 2022-12-16 12:06:05 +02:00
Craig Raw 1fa52f043c add fee column to transactions csv for outgoing (spending) transactions 2022-12-15 17:04:01 +02:00
Craig Raw ce44cfe877 export 8 decimal places when exporting a csv in btc units 2022-12-15 16:37:33 +02:00
Craig Raw 8ba0a9f360 cormorant: increase descriptor wallet gap limit for postmix receive chain 2022-12-15 16:00:56 +02:00
Craig Raw 41dabac75b cormorant: syncing and pruning improvements 2022-12-15 14:03:56 +02:00
Craig Raw 064708f088 avoid copying wallet history unnecessarily on wallet load 2022-12-15 08:29:25 +02:00
Craig Raw 66dc394215 cormorant: send first scan event immediately 2022-12-14 16:29:16 +02:00
Craig Raw 5ca60699ef cormorant: improve scanning behaviour 2022-12-14 14:55:00 +02:00
Craig Raw 61d9ad1875 cormorant: rbf handling and related fixes 2022-12-12 12:55:19 +02:00
Craig Raw af6bbebac4 cormorant: add batching support 2022-12-12 11:33:51 +02:00
Craig Raw 6f4fc4f2ca avoid triggering all history changed event on unconfident script hash status calculations 2022-12-12 10:49:29 +02:00
Craig Raw 00f5001385 cormorant: fix scan date of nested wallet import 2022-12-12 10:09:22 +02:00
Craig Raw 3f3cdca94f refactor out unnecessary parameter 2022-12-12 09:14:17 +02:00
Craig Raw 08cf01a5c6 add cormorant server to support bitcoin core descriptor wallets 2022-12-08 08:42:40 +02:00
Craig Raw df7f40dbc9 followup 2022-12-07 11:00:16 +02:00
Craig Raw 12c1725260 fix edge case when loading wallets with matching tx inputs and outputs 2022-12-06 11:22:35 +02:00
Craig Raw aa8380eb03 add https protocol for bitcoin core connections over tls 2022-12-05 11:57:25 +02:00
Craig Raw 0e26f8fce1 add note on disabling derivation path validation to keystore help tooltip 2022-12-03 07:41:56 +02:00
Craig Raw 8de14dcbce improve handling of certain electrum server errors 2022-12-02 17:16:34 +02:00
Craig Raw 6871810c7c improve display of json rpc error exceptions 2022-12-01 09:39:47 +02:00
Craig Raw 6ac294920e improve encapsulation and binding lifecycle of cell confirmation listeners 2022-11-30 11:12:01 +02:00
Craig Raw 4b32eb397e add seedtool svg icons 2022-11-30 08:31:06 +02:00
Craig Raw b25297e8b9 fix sparrow export file extension to be always mv.db 2022-11-28 12:59:30 +02:00
Craig Raw ff90a2c3e6 add block height to terminal connected label 2022-11-28 09:43:08 +02:00
Craig Raw 3cbe8d1537 set initial focus to done button on terminal server test dialog 2022-11-21 16:21:30 +02:00
Craig Raw 9293b622a3 allow message sign/verify from nested segwit wallets 2022-11-21 16:09:59 +02:00
Craig Raw 6337e1cf7d update dependencies for guava, simple-json-rpc and dependants 2022-11-21 12:08:27 +02:00
craigrawandGitHub 3ff3fb29b0 Merge pull request #753 from lukechilds/patch-1
Update bitcoin.lukechilds.co to bitcoin.lu.ke
2022-11-21 09:09:37 +02:00
craigrawandGitHub 149d297193 Merge pull request #752 from BitcoinQnA/master
Updated Passport Single/Multisig Import and Export Instructions
2022-11-21 09:05:34 +02:00
Craig Raw 47f7b8870c fix cancel of create wallet in terminal 2022-11-21 08:51:09 +02:00
Luke ChildsandGitHub 7ce7d37da7 Update bitcoin.lukechilds.co to bitcoin.lu.ke 2022-11-19 03:30:36 +07:00
BitcoinQnAandGitHub b422c754d6 Update Passport Single-sig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:52:58 +00:00
BitcoinQnAandGitHub be6e9019dc Update Passport Multisig instructions
Suited to the updated Batch 2 UI.
2022-11-18 16:49:26 +00:00
Craig Raw 011bb86b5f fix negative space 2022-11-17 15:45:24 +02:00
Craig Raw 8e1163d3db add seedsigner svg icons 2022-11-17 15:37:04 +02:00
Craig Raw 83c8b1c8e6 use svg icons where possible 2022-11-17 14:06:27 +02:00
Craig Raw d44aecea90 v1.7.1 2022-11-17 10:23:26 +02:00
Craig Raw c9288ab25b change qr code density for ur encoding via qr dialog button 2022-11-17 10:19:04 +02:00
Craig Raw e39a2cb944 fix isconnecting state change 2022-11-17 08:08:50 +02:00
Craig Raw fb25edb51c avoid logging a socket closed error when the connection has been shutdown 2022-11-16 13:05:48 +02:00
Craig Raw 06ff0498d4 revert 29cd321 and add special case for mixed seed/watch only multisig wallets 2022-11-16 11:16:14 +02:00
Craig Raw 63b27e7054 hide spend and freeze context menu items on address cell in utxo table 2022-11-16 10:13:55 +02:00
Craig Raw 0260a12663 close connecting sockets and interrupt read thread on shutdown 2022-11-16 08:16:56 +02:00
Craig Raw a05fcba6d9 add inverted icons for dark theme 2022-11-14 12:45:13 +02:00
Craig Raw 5be5363f25 optimization followup 2022-11-14 11:40:00 +02:00
Craig Raw cc961b4eeb all walletconfig for wallet scope configuration variables 2022-11-14 11:00:26 +02:00
Craig Raw 7e7795196c bring window to front when restored after being minimized to tray 2022-11-09 12:37:55 +02:00
Craig Raw fd0fe1110d improve terminal resizing behaviour 2022-11-09 12:15:12 +02:00
Craig Raw ea64fa0f85 terminal - show receive address as qr code 2022-11-09 11:40:34 +02:00
Craig Raw 2972f1a4d7 fix export from settings tabs for new account by ensuring bidirectional links are restored on save 2022-11-08 15:51:59 +02:00
Craig Raw 6990b398c2 always use db format for sparrow exporter 2022-11-08 15:50:27 +02:00
Craig Raw a25b53bd44 add error message when connecting to bitcoin core with a taproot wallet 2022-11-08 10:36:56 +02:00
Craig Raw 871c503bc9 terminal - add mix selected functionality to broadcast premix transactions 2022-11-08 10:09:25 +02:00
Craig Raw b7992ae9e1 improve .deb control file and avoid dependence on xdg-utils when building sparrow-server debs 2022-11-07 10:08:50 +02:00
Craig Raw 0a8eb2fbb7 avoid triggering close wallet events when reordering tabs 2022-11-07 09:05:08 +02:00
Craig Raw 7863fb7632 delay wallet file deletion to allow for database compaction and show error on failure 2022-11-07 08:29:29 +02:00
Craig Raw 6481d83b0c avoid using locale for unit formatting 2022-11-02 11:40:39 +02:00
Craig Raw 402d9b14ec fix typos and clarify submodule update description 2022-11-02 08:41:50 +02:00
craigrawandGitHub feeb4f0ac0 Merge pull request #730 from RequestPrivacy/master
Add git pull & git submodule update --checkout
2022-11-02 08:35:59 +02:00
RequestPrivacyandGitHub 143eb3213e Formating & Addition of cd Command 2022-11-01 17:42:17 +01:00
RequestPrivacyandGitHub cd96fc1daa Add git pull & git submodule update --checkout 2022-11-01 14:04:22 +01:00
Craig Raw 73d9cd2e68 disable assistive technologies in windows to avoid runtime crash 2022-10-31 17:14:58 +02:00
Craig Raw 3faf817148 consider ip range 100.64.0.0/10 as local network addresses 2022-10-31 08:41:22 +02:00
Craig Raw 96c88b7472 fix npe in terminal for exchange rate updates without a btc rate 2022-10-29 08:33:55 +02:00
Craig Raw e2795c7ef3 fix potential npe selecting server in alias dialog 2022-10-28 09:41:31 +02:00
272 changed files with 8512 additions and 688 deletions
+41 -17
View File
@@ -7,7 +7,7 @@ plugins {
id 'org.beryx.jlink' version '2.25.0'
}
def sparrowVersion = '1.7.0'
def sparrowVersion = '1.7.2'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -54,7 +54,7 @@ dependencies {
exclude group: 'org.hamcrest'
exclude group: 'junit'
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.guava:guava:31.1-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3')
@@ -69,11 +69,11 @@ dependencies {
exclude group: 'com.beust', module: 'jcommander'
}
implementation('com.beust:jcommander:1.81')
implementation('com.github.arteam:simple-json-rpc-core:1.0')
implementation('com.github.arteam:simple-json-rpc-client:1.0') {
implementation('com.github.arteam:simple-json-rpc-core:1.3')
implementation('com.github.arteam:simple-json-rpc-client:1.3') {
exclude group: 'com.github.arteam', module: 'simple-json-rpc-core'
}
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
@@ -103,14 +103,17 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.33')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.34')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.27')
implementation('com.googlecode.lanterna:lanterna:3.1.1')
testImplementation('junit:junit:4.12')
implementation('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2')
implementation('com.sparrowwallet:toucan:0.9.0')
testImplementation('junit:junit:4.13.1')
}
application {
@@ -158,7 +161,8 @@ run {
"--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",
"--add-opens=java.base/java.io=com.google.gson"]
"--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
@@ -207,6 +211,7 @@ jlink {
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop",
"--add-reads=com.sparrowwallet.merged.module=java.sql",
"--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow",
@@ -216,6 +221,9 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor"]
if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
}
if(os.macOsX) {
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
@@ -237,7 +245,12 @@ jlink {
installerType = "exe"
}
if(os.linux) {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow', '--linux-rpm-license-type', 'ASL 2.0']
if(headless) {
installerOptions = ['--license-file', 'LICENSE']
} else {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow']
}
installerOptions += ['--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
}
if(os.macOsX) {
@@ -272,7 +285,7 @@ task packageTarDistribution(type: Tar) {
}
extraJavaModuleInfo {
module('jackson-core-2.10.1.jar', 'com.fasterxml.jackson.core', '2.10.1') {
module('jackson-core-2.13.2.jar', 'com.fasterxml.jackson.core', '2.13.2') {
exports('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.core.async')
exports('com.fasterxml.jackson.core.base')
@@ -287,11 +300,11 @@ extraJavaModuleInfo {
exports('com.fasterxml.jackson.core.util')
uses('com.fasterxml.jackson.core.ObjectCodec')
}
module('jackson-annotations-2.10.1.jar', 'com.fasterxml.jackson.annotation', '2.10.1') {
module('jackson-annotations-2.13.2.jar', 'com.fasterxml.jackson.annotation', '2.13.2') {
requires('com.fasterxml.jackson.core')
exports('com.fasterxml.jackson.annotation')
}
module('jackson-databind-2.10.1.jar', 'com.fasterxml.jackson.databind', '2.10.1') {
module('jackson-databind-2.13.2.jar', 'com.fasterxml.jackson.databind', '2.13.2') {
requires('java.desktop')
requires('java.logging')
requires('com.fasterxml.jackson.annotation')
@@ -325,7 +338,7 @@ extraJavaModuleInfo {
exports('tornadofx.control')
requires('javafx.controls')
}
module('simple-json-rpc-core-1.0.jar', 'simple.json.rpc.core', '1.0') {
module('simple-json-rpc-core-1.3.jar', 'simple.json.rpc.core', '1.3') {
exports('com.github.arteam.simplejsonrpc.core.annotation')
exports('com.github.arteam.simplejsonrpc.core.domain')
requires('com.fasterxml.jackson.core')
@@ -333,7 +346,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind')
requires('org.jetbrains.annotations')
}
module('simple-json-rpc-client-1.0.jar', 'simple.json.rpc.client', '1.0') {
module('simple-json-rpc-client-1.3.jar', 'simple.json.rpc.client', '1.3') {
exports('com.github.arteam.simplejsonrpc.client')
exports('com.github.arteam.simplejsonrpc.client.builder')
exports('com.github.arteam.simplejsonrpc.client.exception')
@@ -341,7 +354,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.databind')
requires('simple.json.rpc.core')
}
module('simple-json-rpc-server-1.0.jar', 'simple.json.rpc.server', '1.0') {
module('simple-json-rpc-server-1.3.jar', 'simple.json.rpc.server', '1.3') {
exports('com.github.arteam.simplejsonrpc.server')
requires('simple.json.rpc.core')
requires('com.google.common')
@@ -397,7 +410,7 @@ extraJavaModuleInfo {
exports('com.google.common.util.concurrent.internal')
}
module('listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar', 'com.google.guava.listenablefuture', '9999.0-empty-to-avoid-conflict-with-guava')
module('guava-28.2-jre.jar', 'com.google.common', '28.2-jre') {
module('guava-31.1-jre.jar', 'com.google.common', '31.1-jre') {
exports('com.google.common.eventbus')
exports('com.google.common.net')
exports('com.google.common.base')
@@ -482,8 +495,10 @@ extraJavaModuleInfo {
}
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
}
module('nightjar-0.2.33.jar', 'com.sparrowwallet.nightjar', '0.2.33') {
module('nightjar-0.2.34.jar', 'com.sparrowwallet.nightjar', '0.2.34') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -571,6 +586,15 @@ extraJavaModuleInfo {
module('jcip-annotations-1.0.jar', 'net.jcip.annotations', '1.0') {
exports('net.jcip.annotations')
}
module('thumbnailator-0.4.18.jar', 'net.coobird.thumbnailator', '0.4.18') {
exports('net.coobird.thumbnailator')
requires('java.desktop')
}
module('fxsvgimage-1.0b2.jar', 'com.github.hervegirod', '1.0b2') {
exports('org.girod.javafx.svgimage')
requires('javafx.graphics')
requires('java.xml')
}
module("netlayer-jpms-${osName}${targetName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
exports('org.berndpruenster.netlayer.tor')
requires('com.github.ravn.jsocks')
+27 -4
View File
@@ -79,16 +79,39 @@ sudo apt install -y rpm fakeroot binutils
### Building the binaries
The project can cloned for a specific release tag as follows:
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="1.7.2"
```
The project can then be initially cloned as follows:
```shell
GIT_TAG="1.7.0"
git clone --recursive --branch "${GIT_TAG}" https://github.com/sparrowwallet/sparrow.git
```
Thereafter, building should be straightforward:
If you already have the sparrow repo cloned, fetch all new updates and checkout the release. For this, change into your local sparrow folder and execute:
```shell
cd sparrow
cd {yourPathToSparrow}/sparrow
git pull --recurse-submodules
git checkout "${GIT_TAG}"
```
Note - there is an additional step if you updated rather than initially cloned your repo at `GIT_TAG`.
This is due to the [drongo submodule](https://github.com/sparrowwallet/drongo/tree/master) which needs to be checked out to the commit state it had at the time of the release.
Only then your build will be comparable to the provided one in the release section of Github.
To checkout the submodule to the correct commit for `GIT_TAG`, additionally run:
```shell
git submodule update --checkout
```
Thereafter, building should be straightforward. If not already done, change into the sparrow folder and run:
```shell
cd {yourPathToSparrow}/sparrow # if you aren't already in the sparrow folder
./gradlew jpackage
```
+1 -1
Submodule drongo updated: 7c34ec7c3b...d48054ac6b
Binary file not shown.
+2 -1
View File
@@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+8 -4
View File
@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,10 +80,10 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@@ -143,12 +143,16 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
Vendored
+1
View File
@@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -5,5 +5,5 @@ Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
Categories=Finance;Network;
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.7.0</string>
<string>1.7.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -222,6 +222,41 @@ public class AppController implements Initializable {
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
};
private final ListChangeListener<Tab> tabsChangeListener = (c) -> {
if(c.next() && (c.wasAdded() || c.wasRemoved())) {
if(c.wasRemoved() && previouslySelectedTab != null) {
tabs.getSelectionModel().select(previouslySelectedTab);
}
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData) tab.getUserData()).getType() == TabData.TabType.WALLET);
boolean walletRemoved = c.getRemoved().stream().anyMatch(tab -> ((TabData) tab.getUserData()).getType() == TabData.TabType.WALLET);
if(walletAdded || walletRemoved) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
}
List<WalletTabData> closedWalletTabs = c.getRemoved().stream().filter(tab -> tab.getUserData() instanceof WalletTabData)
.flatMap(tab -> ((TabPane) tab.getContent()).getTabs().stream().map(subTab -> (WalletTabData) subTab.getUserData())).collect(Collectors.toList());
if(!closedWalletTabs.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedWalletTabs));
}
List<TransactionTabData> closedTransactionTabs = c.getRemoved().stream().map(tab -> (TabData) tab.getUserData())
.filter(tabData -> tabData.getType() == TabData.TabType.TRANSACTION).map(tabData -> (TransactionTabData) tabData).collect(Collectors.toList());
if(!closedTransactionTabs.isEmpty()) {
EventManager.get().post(new TransactionTabsClosedEvent(closedTransactionTabs));
}
closeTab.setDisable(tabs.getTabs().isEmpty());
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage) tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
exportWallet.setDisable(true);
}
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -278,40 +313,7 @@ public class AppController implements Initializable {
//Draggle tabs introduce unwanted movement when selecting between them
//tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER);
tabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
if(c.next() && (c.wasAdded() || c.wasRemoved())) {
if(c.wasRemoved() && previouslySelectedTab != null) {
tabs.getSelectionModel().select(previouslySelectedTab);
}
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
boolean walletRemoved = c.getRemoved().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
if(walletAdded || walletRemoved) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
}
List<WalletTabData> closedWalletTabs = c.getRemoved().stream().filter(tab -> tab.getUserData() instanceof WalletTabData)
.flatMap(tab -> ((TabPane)tab.getContent()).getTabs().stream().map(subTab -> (WalletTabData)subTab.getUserData())).collect(Collectors.toList());
if(!closedWalletTabs.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedWalletTabs));
}
List<TransactionTabData> closedTransactionTabs = c.getRemoved().stream().map(tab -> (TabData)tab.getUserData())
.filter(tabData -> tabData.getType() == TabData.TabType.TRANSACTION).map(tabData -> (TransactionTabData)tabData).collect(Collectors.toList());
if(!closedTransactionTabs.isEmpty()) {
EventManager.get().post(new TransactionTabsClosedEvent(closedTransactionTabs));
}
closeTab.setDisable(tabs.getTabs().isEmpty());
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage)tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
exportWallet.setDisable(true);
}
}
});
tabs.getTabs().addListener(tabsChangeListener);
tabs.getScene().getWindow().setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -384,7 +386,7 @@ public class AppController implements Initializable {
configureSwitchServer();
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
serverToggle.setDisable(Config.get().getServerType() == null);
serverToggle.setDisable(!Config.get().hasServer());
onlineProperty().bindBidirectional(serverToggle.selectedProperty());
onlineProperty().addListener(new WeakChangeListener<>(serverToggleOnlineListener));
serverToggle.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
@@ -942,6 +944,8 @@ public class AppController implements Initializable {
if(AppServices.isConnected()) {
return "Connected to " + Config.get().getServerDisplayName() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") +
(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? "\nWarning! You are connected to a public server and sharing your transaction data with it.\nFor better privacy, consider using your own Bitcoin Core node or private Electrum server." : "");
} else if(AppServices.isConnecting()) {
return "Connecting...";
}
return "Disconnected";
@@ -1064,11 +1068,14 @@ public class AppController implements Initializable {
}
public void importWallet(ActionEvent event) {
WalletImportDialog dlg = new WalletImportDialog();
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get();
addImportedWallet(wallet);
if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
}
}
}
@@ -1241,6 +1248,7 @@ public class AppController implements Initializable {
PreferencesDialog preferencesDialog = new PreferencesDialog(preferenceGroup);
preferencesDialog.showAndWait();
configureSwitchServer();
serverToggle.setDisable(!Config.get().hasServer());
}
public void signVerifyMessage(ActionEvent event) {
@@ -1461,11 +1469,10 @@ public class AppController implements Initializable {
wallet.setName(name);
}
Tab tab = new Tab("");
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
glyph.setFontSize(10.0);
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
WalletIcon walletIcon = new WalletIcon(storage, wallet);
walletIcon.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
Label tabLabel = new Label(name);
tabLabel.setGraphic(glyph);
tabLabel.setGraphic(walletIcon);
tabLabel.setGraphicTextGap(5.0);
tab.setGraphic(tabLabel);
tab.setClosable(true);
@@ -1597,7 +1604,7 @@ public class AppController implements Initializable {
TabData tabData = new WalletTabData(TabData.TabType.WALLET, walletForm);
subTab.setUserData(tabData);
if(!wallet.isWhirlpoolChildWallet()) {
subTab.setContextMenu(getSubTabContextMenu(wallet, subTabs, subTab));
subTab.setContextMenu(getSubTabContextMenu(storage, wallet, subTabs, subTab));
}
subTabs.getTabs().add(subTab);
@@ -1608,6 +1615,10 @@ public class AppController implements Initializable {
});
subTabs.getSelectionModel().select(subTab);
if(wallet.isValid()) {
Platform.runLater(() -> walletForm.refreshHistory(AppServices.getCurrentBlockHeight()));
}
return walletForm;
} catch(IOException e) {
throw new RuntimeException(e);
@@ -1646,6 +1657,19 @@ public class AppController implements Initializable {
return null;
}
public List<WalletForm> getSelectedWalletForms() {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
if(selectedTab != null) {
TabData tabData = (TabData) selectedTab.getUserData();
if(tabData instanceof WalletTabData) {
TabPane subTabs = (TabPane) selectedTab.getContent();
return subTabs.getTabs().stream().map(subTab -> ((WalletTabData) subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
}
}
return Collections.emptyList();
}
private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException {
if(Utils.isBase64(string) && !Utils.isHex(string)) {
addTransactionTab(name, file, Base64.getDecoder().decode(string));
@@ -1813,15 +1837,19 @@ public class AppController implements Initializable {
MenuItem moveRight = new MenuItem("Move Right");
moveRight.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index + 1, tab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
});
MenuItem moveLeft = new MenuItem("Move Left");
moveLeft.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index - 1, tab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
});
contextMenu.getItems().addAll(moveRight, moveLeft);
@@ -1850,17 +1878,61 @@ public class AppController implements Initializable {
contextMenu.getItems().addAll(new SeparatorMenuItem(), close, closeOthers, closeAll);
if(tab.getUserData() instanceof WalletTabData) {
if(tab.getUserData() instanceof WalletTabData walletTabData) {
Menu walletIcon = new Menu("Wallet Icon");
MenuItem custom = new MenuItem("Custom...");
custom.setOnAction(event -> {
setCustomIcon(walletTabData.getWallet());
});
MenuItem reset = new MenuItem("Reset");
reset.setOnAction(event -> {
resetIcon(walletTabData.getWalletForm());
});
walletIcon.getItems().addAll(custom, reset);
MenuItem delete = new MenuItem("Delete...");
delete.setOnAction(event -> {
deleteWallet(getSelectedWalletForm());
deleteWallet(walletTabData.getWalletForm());
});
contextMenu.getItems().addAll(new SeparatorMenuItem(), delete);
contextMenu.getItems().addAll(new SeparatorMenuItem(), walletIcon, delete);
}
return contextMenu;
}
private void setCustomIcon(Wallet wallet) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open Image");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("Images", "*.png", "*.jpg", "*.jpeg", "*.gif")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
try {
byte[] iconData = ImageUtils.resize(file, WalletIcon.SAVE_WIDTH, WalletIcon.SAVE_HEIGHT);
WalletConfig walletConfig = wallet.getMasterWalletConfig();
walletConfig.setIconData(iconData, true);
EventManager.get().post(new WalletConfigChangedEvent(wallet));
} catch(Exception e) {
log.error("Error creating custom wallet icon", e);
showErrorDialog("Error creating custom wallet icon", e.getMessage());
}
}
}
private void resetIcon(WalletForm walletForm) {
Wallet masterWallet = walletForm.getMasterWallet();
if(masterWallet.getWalletConfig() != null && masterWallet.getWalletConfig().isUserIcon()) {
masterWallet.getWalletConfig().setIconData(null, false);
EventManager.get().post(new WalletConfigChangedEvent(masterWallet));
}
}
private void deleteWallet(WalletForm selectedWalletForm) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete " + selectedWalletForm.getWallet().getMasterName() + "?", "The wallet file and any backups will be deleted. Are you sure?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
@@ -1905,13 +1977,30 @@ public class AppController implements Initializable {
private void deleteStorage(Storage storage) {
if(storage.isClosed()) {
Platform.runLater(storage::delete);
Platform.runLater(() -> {
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage);
deleteWalletService.setDelay(Duration.seconds(3));
deleteWalletService.setPeriod(Duration.hours(1));
deleteWalletService.setOnSucceeded(event -> {
deleteWalletService.cancel();
if(!deleteWalletService.getValue()) {
showErrorDialog("Error deleting wallet", "Could not delete " + storage.getWalletFile().getName() + ". Please delete this file manually.");
}
});
deleteWalletService.setOnFailed(event -> {
deleteWalletService.cancel();
showErrorDialog("Error deleting wallet", "Could not delete " + storage.getWalletFile().getName() + ". Please delete this file manually.");
});
deleteWalletService.start();
});
} else {
Platform.runLater(() -> deleteStorage(storage));
}
}
private ContextMenu getSubTabContextMenu(Wallet wallet, TabPane subTabs, Tab subTab) {
private ContextMenu getSubTabContextMenu(Storage storage, Wallet wallet, TabPane subTabs, Tab subTab) {
String walletId = storage.getWalletId(wallet);
ContextMenu contextMenu = new ContextMenu();
MenuItem rename = new MenuItem("Rename Account");
rename.setOnAction(event -> {
@@ -1922,8 +2011,9 @@ public class AppController implements Initializable {
String label = optLabel.get();
subTabLabel.setText(label);
wallet.setLabel(label);
EventManager.get().post(new WalletLabelChangedEvent(wallet));
Wallet renamedWallet = AppServices.get().getWallet(walletId);
renamedWallet.setLabel(label);
EventManager.get().post(new WalletLabelChangedEvent(renamedWallet));
}
});
contextMenu.getItems().add(rename);
@@ -1933,11 +2023,12 @@ public class AppController implements Initializable {
delete.setOnAction(event -> {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete Wallet Account?", "Labels applied in this wallet account will be lost. Are you sure?", ButtonType.CANCEL, ButtonType.OK);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.OK) {
Wallet deletedWallet = AppServices.get().getWallet(walletId);
EventManager.get().post(new WalletDeletedEvent(deletedWallet));
subTabs.getTabs().remove(subTab);
if(subTabs.getTabs().size() == 1) {
setSubTabsVisible(subTabs, areSubTabsVisible());
}
EventManager.get().post(new WalletDeletedEvent(wallet));
}
});
contextMenu.getItems().add(delete);
@@ -2001,7 +2092,7 @@ public class AppController implements Initializable {
serverToggle.getStyleClass().remove("core-server");
}
serverToggle.setDisable(false);
serverToggle.setDisable(!Config.get().hasServer());
}
public void setTheme(ActionEvent event) {
@@ -2050,8 +2141,8 @@ public class AppController implements Initializable {
private void tabLabelAddFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
if(!tabLabel.getStyleClass().contains("failure")) {
tabLabel.getGraphic().getStyleClass().add("failure");
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
if(walletIcon.addFailure()) {
tabLabel.setTooltip(new Tooltip("Error loading transaction history from server"));
}
}
@@ -2084,7 +2175,8 @@ public class AppController implements Initializable {
private void tabLabelRemoveFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
tabLabel.getGraphic().getStyleClass().remove("failure");
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.removeFailure();
tabLabel.setTooltip(null);
}
@@ -2121,6 +2213,14 @@ public class AppController implements Initializable {
} else {
tabs.getScene().getStylesheets().remove(darkCss);
}
for(Tab tab : tabs.getTabs()) {
if(tab.getUserData() instanceof WalletTabData) {
Label tabLabel = (Label)tab.getGraphic();
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.refresh();
}
}
}
@Subscribe
@@ -2201,6 +2301,9 @@ public class AppController implements Initializable {
Tab masterTab = subTabs.getTabs().stream().filter(tab -> ((WalletTabData)tab.getUserData()).getWallet().isMasterWallet()).findFirst().orElse(subTabs.getTabs().get(0));
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(event.getWallet().getLabel() != null ? event.getWallet().getLabel() : event.getWallet().getAutomaticName());
Label tabLabel = (Label)walletTab.getGraphic();
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.setWallet(event.getWallet());
}
}
}
@@ -2511,6 +2614,50 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void cormorantSyncStatus(CormorantSyncStatusEvent event) {
serverToggle.setDisable(false);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Syncing... (" + event.getProgress() + "% complete, synced to " + event.getTipAsString() + ")"));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
}
}
@Subscribe
public void cormorantScanStatus(CormorantScanStatusEvent event) {
serverToggle.setDisable(true);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Scanning... (" + event.getProgress() + "% complete" + (event.getRemainingAsString().isEmpty() ? ")" : ", " + event.getRemainingAsString() + " remaining)")));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
} else if(event.isCompleted()) {
serverToggle.setDisable(false);
if(statusBar.getText().startsWith("Scanning...")) {
statusBar.setText("");
}
}
}
@Subscribe
public void cormorantPruneStatus(CormorantPruneStatusEvent event) {
if(event.legacyWalletExists()) {
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".\n\n" +
"Do you want to try using the existing legacy Bitcoin Core wallet?", ButtonType.YES, ButtonType.NO);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
Config.get().setUseLegacyCoreWallet(true);
onlineProperty().set(false);
Platform.runLater(() -> onlineProperty().set(true));
}
} else {
AppServices.showErrorDialog("Error importing Bitcoin Core descriptor wallet",
"The connected node is pruned at " + event.getPruneDateAsString() + ", but the wallet birthday for " + event.getWallet().getFullDisplayName() + " is set to " + event.getScanDateAsString() + ".");
}
}
@Subscribe
public void bwtBootStatus(BwtBootStatusEvent event) {
serverToggle.setDisable(true);
@@ -2728,4 +2875,17 @@ public class AppController implements Initializable {
lockAllWallets.setDisable(false);
}
}
@Subscribe
public void walletConfigChanged(WalletConfigChangedEvent event) {
for(Tab tab : tabs.getTabs()) {
if(tab.getUserData() instanceof WalletTabData walletTabData) {
if(walletTabData.getWallet() == event.getWallet()) {
Label tabLabel = (Label)tab.getGraphic();
WalletIcon walletIcon = (WalletIcon)tabLabel.getGraphic();
walletIcon.refresh();
}
}
}
}
}
@@ -284,26 +284,25 @@ public class AppServices {
});
connectionService.setOnFailed(failEvent -> {
//Close connection here to create a new transport next time we try
connectionService.resetConnection();
connectionService.closeConnection();
if(failEvent.getSource().getException() instanceof ServerConfigException) {
connectionService.setRestartOnFailure(false);
}
if(failEvent.getSource().getException() instanceof TlsServerException && failEvent.getSource().getException().getCause() != null) {
TlsServerException tlsServerException = (TlsServerException)failEvent.getSource().getException();
if(failEvent.getSource().getException() instanceof TlsServerException tlsServerException && failEvent.getSource().getException().getCause() != null) {
connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert();
if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
"\n\nChange the configured server certificate if you would like to proceed.");
} else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(crtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nThis may be simply due to a certificate renewal, or it may indicate a man-in-the-middle attack." +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
if(crtFile.delete()) {
@@ -315,10 +314,19 @@ public class AppServices {
}
}
}
} else if(tlsServerException.getCause().getCause() instanceof UnknownCertificateExpiredException expiredException) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " has expired. "
+ tlsServerException.getMessage() + "." +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
Storage.saveCertificate(tlsServerException.getServer().getHost(), expiredException.getCertificate());
Platform.runLater(() -> restartService(connectionService));
return;
}
}
}
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().requiresTor()) {
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().isAutoSwitchProxy() && Config.get().requiresTor()) {
Config.get().setUseProxy(false);
Platform.runLater(() -> restartService(torService));
return;
@@ -1104,7 +1112,7 @@ public class AppServices {
Wallet wallet = walletTabData.getWallet();
Storage storage = walletTabData.getStorage();
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB))) {
if(Interface.get() == Interface.DESKTOP && (!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB) || CardApi.isReaderAvailable())) {
usbWallet = true;
if(deviceEnumerateService == null) {
@@ -1139,10 +1147,12 @@ public class AppServices {
@Subscribe
public void requestDisconnect(RequestDisconnectEvent event) {
onlineProperty.set(false);
//Ensure services don't try to reconnect
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
//Ensure services don't try to reconnect later
Platform.runLater(() -> {
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
});
}
@Subscribe
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.WalletIcon;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
@@ -18,6 +19,7 @@ import org.controlsfx.tools.Platform;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.URL;
import java.util.*;
import java.util.stream.Collectors;
@@ -43,6 +45,7 @@ public class SparrowDesktop extends Application {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
AppServices.initialize(this);
@@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.7.0";
public static final String APP_VERSION = "1.7.2";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -8,15 +8,20 @@ import java.util.Locale;
public enum UnitFormat {
DOT {
private final DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat btcFormat = new DecimalFormat("0", getDecimalFormatSymbols());
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getSatsFormat() {
return satsFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
@@ -25,20 +30,28 @@ public enum UnitFormat {
return currencyFormat;
}
public Locale getLocale() {
return Locale.ENGLISH;
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator('.');
symbols.setGroupingSeparator(',');
return symbols;
}
},
COMMA {
private final DecimalFormat btcFormat = new DecimalFormat("0", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(getLocale()));
private final DecimalFormat btcFormat = new DecimalFormat("0", getDecimalFormatSymbols());
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
public DecimalFormat getBtcFormat() {
btcFormat.setMaximumFractionDigits(8);
return btcFormat;
}
public DecimalFormat getSatsFormat() {
return satsFormat;
}
public DecimalFormat getTableBtcFormat() {
return tableBtcFormat;
}
@@ -47,15 +60,20 @@ public enum UnitFormat {
return currencyFormat;
}
public Locale getLocale() {
return Locale.GERMAN;
public DecimalFormatSymbols getDecimalFormatSymbols() {
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
symbols.setDecimalSeparator(',');
symbols.setGroupingSeparator('.');
return symbols;
}
};
public abstract Locale getLocale();
public abstract DecimalFormatSymbols getDecimalFormatSymbols();
public abstract DecimalFormat getBtcFormat();
public abstract DecimalFormat getSatsFormat();
public abstract DecimalFormat getTableBtcFormat();
public abstract DecimalFormat getCurrencyFormat();
@@ -64,18 +82,18 @@ public enum UnitFormat {
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String tableFormatBtcValue(Long amount) {
return getTableBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
}
public String formatSatsValue(Long amount) {
return String.format(getLocale(), "%,d", amount);
return getSatsFormat().format(amount);
}
public String formatCurrencyValue(double amount) {
return getCurrencyFormat().format(amount);
}
public DecimalFormatSymbols getDecimalFormatSymbols() {
return DecimalFormatSymbols.getInstance(getLocale());
}
public String getGroupingSeparator() {
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
}
@@ -40,7 +40,7 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
if(utxoEntry != null) {
Address address = addressStatus.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
@@ -0,0 +1,234 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class CardImportPane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
private final KeystoreCardImport importer;
private final List<ChildNumber> derivation;
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png");
this.importer = importer;
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
}
@Override
protected Control createButton() {
importButton = new Button("Import");
Glyph tapGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
tapGlyph.setFontSize(12);
importButton.setGraphic(tapGlyph);
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setOnAction(event -> {
importButton.setDisable(true);
importCard();
});
return importButton;
}
private void importCard() {
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getPinEntry());
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
return;
}
StringProperty messageProperty = new SimpleStringProperty();
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
try {
if(!importer.isInitialized()) {
setDescription("Card not initialized");
setContent(getInitializationPanel(messageProperty));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
} catch(CardException e) {
setError("Card Error", e.getMessage());
importButton.setDisable(false);
return;
}
CardImportService cardImportService = new CardImportService(importer, pin.get(), derivation, messageProperty);
cardImportService.setOnSucceeded(event -> {
EventManager.get().post(new KeystoreImportEvent(cardImportService.getValue()));
});
cardImportService.setOnFailed(event -> {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
} else {
log.error("Error importing keystore from card", event.getSource().getException());
setError("Import Error", rootCause.getMessage());
}
importButton.setDisable(false);
});
cardImportService.start();
}
private Node getInitializationPanel(StringProperty messageProperty) {
VBox initTypeBox = new VBox(5);
RadioButton automatic = new RadioButton("Automatic (Recommended)");
RadioButton advanced = new RadioButton("Advanced");
TextField entropy = new TextField();
entropy.setPromptText("Enter input for user entropy");
entropy.setDisable(true);
ToggleGroup toggleGroup = new ToggleGroup();
automatic.setToggleGroup(toggleGroup);
advanced.setToggleGroup(toggleGroup);
automatic.setSelected(true);
toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
entropy.setDisable(newValue == automatic);
});
initTypeBox.getChildren().addAll(automatic, advanced, entropy);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8));
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), chainCode, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
setDescription("Leave card on reader");
setExpanded(false);
importButton.setDisable(false);
});
cardInitializationService.setOnFailed(failEvent -> {
Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
importButton.setDisable(false);
} else {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
}
});
cardInitializationService.start();
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(initTypeBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(initTypeBox, Priority.ALWAYS);
return contentBox;
}
private Node getPinEntry() {
VBox vBox = new VBox();
CustomPasswordField pinField = new ViewPasswordField();
pinField.setPromptText("PIN Code");
importButton.setDefaultButton(true);
pin.bind(pinField.textProperty());
HBox.setHgrow(pinField, Priority.ALWAYS);
Platform.runLater(pinField::requestFocus);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
return vBox;
}
public static class CardInitializationService extends Service<Void> {
private final KeystoreCardImport cardImport;
private final String pin;
private final byte[] chainCode;
private final StringProperty messageProperty;
public CardInitializationService(KeystoreCardImport cardImport, String pin, byte[] chainCode, StringProperty messageProperty) {
this.cardImport = cardImport;
this.pin = pin;
this.chainCode = chainCode;
this.messageProperty = messageProperty;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
cardImport.initialize(pin, chainCode, messageProperty);
return null;
}
};
}
}
public static class CardImportService extends Service<Keystore> {
private final KeystoreCardImport cardImport;
private final String pin;
private final List<ChildNumber> derivation;
private final StringProperty messageProperty;
public CardImportService(KeystoreCardImport cardImport, String pin, List<ChildNumber> derivation, StringProperty messageProperty) {
this.cardImport = cardImport;
this.pin = pin;
this.derivation = derivation;
this.messageProperty = messageProperty;
}
@Override
protected Task<Keystore> createTask() {
return new Task<>() {
@Override
protected Keystore call() throws Exception {
return cardImport.getKeystore(pin, derivation, messageProperty);
}
};
}
}
}
@@ -0,0 +1,93 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.scene.control.*;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
private final CustomPasswordField existingPin;
private final CustomPasswordField newPin;
private final CustomPasswordField newPinConfirm;
private final CheckBox backupFirst;
private final ButtonType okButtonType;
public CardPinDialog() {
this.existingPin = new ViewPasswordField();
this.newPin = new ViewPasswordField();
this.newPinConfirm = new ViewPasswordField();
this.backupFirst = new CheckBox();
final DialogPane dialogPane = getDialogPane();
setTitle("Change Card PIN");
dialogPane.setHeaderText("Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
lock.setFontSize(50);
dialogPane.setGraphic(lock);
Form form = new Form();
Fieldset fieldset = new Fieldset();
fieldset.setText("");
fieldset.setSpacing(10);
Field currentField = new Field();
currentField.setText("Current PIN:");
currentField.getInputs().add(existingPin);
Field newField = new Field();
newField.setText("New PIN:");
newField.getInputs().add(newPin);
Field confirmField = new Field();
confirmField.setText("Confirm new PIN:");
confirmField.getInputs().add(newPinConfirm);
Field backupField = new Field();
backupField.setText("Backup First:");
backupField.getInputs().add(backupFirst);
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < 6 || existingPin.getText().length() > 32));
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < 6 || newPin.getText().length() > 32));
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
});
okButtonType = new javafx.scene.control.ButtonType("Change", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
okButton.setPrefWidth(130);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < 6 || existingPin.getText().length() > 32
|| newPin.getText().length() < 6 || newPin.getText().length() > 32
|| !newPin.getText().equals(newPinConfirm.getText()),
existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty());
okButton.disableProperty().bind(isInvalid);
Platform.runLater(existingPin::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new CardPinChange(existingPin.getText(), newPin.getText(), backupFirst.isSelected()) : null);
}
public record CardPinChange(String currentPin, String newPin, boolean backupFirst) { }
}
@@ -2,11 +2,14 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
@@ -16,12 +19,14 @@ import org.controlsfx.tools.Platform;
import java.text.DecimalFormat;
class CoinCell extends TreeTableCell<Entry, Number> {
private final Tooltip tooltip;
class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsListener {
private final CoinTooltip tooltip;
private IntegerProperty confirmationsProperty;
public CoinCell() {
super();
tooltip = new Tooltip();
tooltip = new CoinTooltip();
tooltip.setShowDelay(Duration.millis(500));
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
@@ -50,21 +55,16 @@ class CoinCell extends TreeTableCell<Entry, Number> {
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
tooltip.setValue(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
setText(btcValue);
} else {
tooltip.setText(btcValue + " " + BitcoinUnit.BTC.getLabel());
tooltip.setValue(btcValue + " " + BitcoinUnit.BTC.getLabel());
setText(satsValue);
}
setTooltip(tooltip);
String tooltipValue = tooltip.getText();
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
tooltip.setText(tooltipValue + " (" + transactionEntry.getConfirmationsDescription() + ")");
});
tooltip.showConfirmations(transactionEntry.confirmationsProperty());
if(transactionEntry.isConfirming()) {
ConfirmationProgressIndicator arc = new ConfirmationProgressIndicator(transactionEntry.getConfirmations());
@@ -94,4 +94,65 @@ class CoinCell extends TreeTableCell<Entry, Number> {
}
}
}
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static final class CoinTooltip extends Tooltip {
private final IntegerProperty confirmationsProperty = new SimpleIntegerProperty();
private boolean showConfirmations;
private String value;
public void setValue(String value) {
this.value = value;
setTooltipText();
}
public void showConfirmations(IntegerProperty txEntryConfirmationsProperty) {
showConfirmations = true;
int confirmations = txEntryConfirmationsProperty.get();
if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
confirmationsProperty.bind(txEntryConfirmationsProperty);
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
setTooltipText();
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
confirmationsProperty.unbind();
}
});
} else {
confirmationsProperty.unbind();
confirmationsProperty.set(confirmations);
}
setTooltipText();
}
private void setTooltipText() {
setText(value + (showConfirmations ? " (" + getConfirmationsDescription() + ")" : ""));
}
public String getConfirmationsDescription() {
int confirmations = confirmationsProperty.get();
if(confirmations == 0) {
return "Unconfirmed in mempool";
} else if(confirmations < BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM) {
return confirmations + " confirmation" + (confirmations == 1 ? "" : "s");
} else {
return BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM + "+ confirmations";
}
}
}
}
@@ -30,7 +30,7 @@ public class CoinTextFormatter extends TextFormatter<String> {
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", DecimalFormatSymbols.getInstance(unitFormat.getLocale()));
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@@ -93,7 +93,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> {
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate());
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate(), false);
Optional<Date> optDate = dlg.showAndWait();
if(optDate.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.property.IntegerProperty;
public interface ConfirmationsListener {
IntegerProperty getConfirmationsProperty();
}
@@ -68,7 +68,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
stackPane.getChildren().addAll(anchorPane, scanBox);
List<Device> devices = AppServices.getDevices();
List<Device> devices = getDevices();
if(devices == null || devices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
@@ -96,6 +96,10 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
}
protected List<Device> getDevices() {
return AppServices.getDevices();
}
private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
@@ -9,11 +9,11 @@ import com.sparrowwallet.sparrow.io.Device;
import java.util.stream.Collectors;
public class DeviceAddressDialog extends DeviceDialog<String> {
public class DeviceDisplayAddressDialog extends DeviceDialog<String> {
private final Wallet wallet;
private final OutputDescriptor outputDescriptor;
public DeviceAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
public DeviceDisplayAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
super(outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList()));
this.wallet = wallet;
this.outputDescriptor = outputDescriptor;
@@ -0,0 +1,29 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.DeviceAddressEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceGetAddressDialog extends DeviceDialog<Address> {
public DeviceGetAddressDialog(List<String> operationFingerprints) {
super(operationFingerprints);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(DevicePane.DeviceOperation.GET_ADDRESS, device, defaultDevice);
}
@Subscribe
public void deviceAddress(DeviceAddressEvent event) {
setResult(event.getAddress());
}
}
@@ -1,25 +1,33 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
@@ -34,8 +42,10 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class DevicePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
@@ -58,8 +68,12 @@ public class DevicePane extends TitledDescriptionPane {
private Button displayAddressButton;
private Button signMessageButton;
private Button discoverKeystoresButton;
private ButtonBase getPrivateKeyButton;
private Button getAddressButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
private final SimpleStringProperty pin = new SimpleStringProperty("");
private final StringProperty messageProperty = new SimpleStringProperty("");
private boolean defaultDevice;
@@ -83,13 +97,17 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, importButton);
}
public DevicePane(PSBT psbt, Device device, boolean defaultDevice) {
public DevicePane(Wallet wallet, PSBT psbt, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null;
this.wallet = wallet;
this.psbt = psbt;
this.outputDescriptor = null;
this.keyDerivation = null;
@@ -106,6 +124,10 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
}
@@ -152,6 +174,10 @@ public class DevicePane extends TitledDescriptionPane {
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
@@ -178,6 +204,41 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton);
}
public DevicePane(DeviceOperation deviceOperation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = deviceOperation;
this.wallet = null;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
this.availableAccounts = null;
setDefaultStatus();
showHideLink.setVisible(false);
ButtonBase button;
if(deviceOperation == DeviceOperation.GET_PRIVATE_KEY) {
createGetPrivateKeyButton();
button = getPrivateKeyButton;
} else if(deviceOperation == DeviceOperation.GET_ADDRESS) {
createGetAddressButton();
button = getAddressButton;
} else {
throw new UnsupportedOperationException("Cannot construct device pane for operation " + deviceOperation);
}
initialise(device);
messageProperty.addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setDescription(newValue));
});
buttonBox.getChildren().add(button);
}
private void initialise(Device device) {
if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice);
@@ -201,7 +262,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private void setDefaultStatus() {
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : device.isCard() ? "Leave card on reader" : "Unlocked");
}
private void createUnlockButton() {
@@ -321,6 +382,60 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setVisible(false);
}
private void createGetPrivateKeyButton() {
int currentSlot = 0;
boolean initialized = true;
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), null);
currentSlot = cardApi.getCurrentSlot();
initialized = cardApi.isInitialized();
} catch(Exception e) {
//ignore
}
getPrivateKeyButton = currentSlot > 0 ? new SplitMenuButton() : new Button();
getPrivateKeyButton.setAlignment(Pos.CENTER_RIGHT);
getPrivateKeyButton.setText("Get Private Key");
getPrivateKeyButton.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(null);
});
if(getPrivateKeyButton instanceof SplitMenuButton getPrivateKeyMenuButton) {
int[] previousSlots = IntStream.range(0, currentSlot).toArray();
for(int previousSlot : previousSlots) {
MenuItem previousSlotItem = new MenuItem("Slot #" + previousSlot);
previousSlotItem.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(previousSlot);
});
getPrivateKeyMenuButton.getItems().add(previousSlotItem);
}
if(initialized) {
int finalSlot = currentSlot;
MenuItem currentSlotItem = new MenuItem("Current Slot");
currentSlotItem.setOnAction(event -> {
getPrivateKeyButton.setDisable(true);
getPrivateKey(finalSlot);
});
getPrivateKeyMenuButton.getItems().add(currentSlotItem);
}
}
getPrivateKeyButton.managedProperty().bind(getPrivateKeyButton.visibleProperty());
getPrivateKeyButton.setVisible(false);
}
private void createGetAddressButton() {
getAddressButton = new Button("Get Address");
getAddressButton.setAlignment(Pos.CENTER_RIGHT);
getAddressButton.setOnAction(event -> {
getAddressButton.setDisable(true);
getAddress();
});
getAddressButton.managedProperty().bind(getAddressButton.visibleProperty());
getAddressButton.setVisible(false);
}
private void unlock(Device device) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
@@ -554,7 +669,36 @@ public class DevicePane extends TitledDescriptionPane {
}
private void importKeystore(List<ChildNumber> derivation) {
if(device.getFingerprint() == null) {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(importButton));
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
return;
}
setDescription("Card not initialized");
setContent(getCardInitializationPanel(cardApi, importButton, DeviceOperation.IMPORT));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
Service<Keystore> importService = cardApi.getImportService(derivation, messageProperty);
handleCardOperation(importService, importButton, "Import", true, event -> {
importKeystore(derivation, importService.getValue());
});
} catch(Exception e) {
log.error("Import Error: " + e.getMessage(), e);
setError("Import Error", e.getMessage());
importButton.setDisable(false);
}
} else if(device.getFingerprint() == null) {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get());
enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue();
@@ -591,18 +735,7 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
if(wallet.getScriptType() == null) {
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
importKeystore(derivation, keystore);
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
@@ -616,20 +749,74 @@ public class DevicePane extends TitledDescriptionPane {
getXpubService.start();
}
private void importKeystore(List<ChildNumber> derivation, Keystore keystore) {
if(wallet.getScriptType() == null) {
ScriptType scriptType = Arrays.stream(ScriptType.ADDRESSABLE_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
}
private void sign() {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt);
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<PSBT> signService = cardApi.getSignService(wallet, psbt, messageProperty);
handleCardOperation(signService, signButton, "Signing", true, event -> {
EventManager.get().post(new PSBTSignedEvent(psbt, signService.getValue()));
});
} catch(Exception e) {
log.error("Signing Error: " + e.getMessage(), e);
setError("Signing Error", e.getMessage());
signButton.setDisable(false);
}
} else {
Hwi.SignPSBTService signPSBTService = new Hwi.SignPSBTService(device, passphrase.get(), psbt);
signPSBTService.setOnSucceeded(workerStateEvent -> {
PSBT signedPsbt = signPSBTService.getValue();
EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt));
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
showHideLink.setVisible(false);
signPSBTService.start();
}
}
private void handleCardOperation(Service<?> service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler<WorkerStateEvent> successHandler) {
if(pinRequired && pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(operationButton));
showHideLink.setVisible(false);
setExpanded(true);
operationButton.setDisable(false);
return;
}
service.setOnSucceeded(successHandler);
service.setOnFailed(event -> {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getCardPinEntry(operationButton));
} else {
log.error(operationDescription + " Error: " + rootCause.getMessage(), event.getSource().getException());
setError(operationDescription + " Error", rootCause.getMessage());
}
operationButton.setDisable(false);
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
showHideLink.setVisible(false);
signPSBTService.start();
service.start();
}
private void displayAddress() {
@@ -647,17 +834,32 @@ public class DevicePane extends TitledDescriptionPane {
}
private void signMessage() {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<String> signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty);
handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
} catch(Exception e) {
log.error("Signing Error: " + e.getMessage(), e);
setError("Signing Error", e.getMessage());
signButton.setDisable(false);
}
} else {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
}
}
private void discoverKeystores() {
@@ -717,6 +919,55 @@ public class DevicePane extends TitledDescriptionPane {
getXpubsService.start();
}
private void getPrivateKey(Integer slot) {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
Service<ECKey> privateKeyService = cardApi.getPrivateKeyService(slot, messageProperty);
handleCardOperation(privateKeyService, getPrivateKeyButton, "Private Key", true, event -> {
EventManager.get().post(new DeviceGetPrivateKeyEvent(privateKeyService.getValue(), cardApi.getDefaultScriptType()));
});
} catch(Exception e) {
log.error("Private Key Error: " + e.getMessage(), e);
setError("Private Key Error", e.getMessage());
getPrivateKeyButton.setDisable(false);
}
}
}
private void getAddress() {
if(device.isCard()) {
try {
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
if(!cardApi.isInitialized()) {
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getCardPinEntry(getAddressButton));
showHideLink.setVisible(false);
setExpanded(true);
getAddressButton.setDisable(false);
return;
}
setDescription("Card not initialized");
setContent(getCardInitializationPanel(cardApi, getAddressButton, DeviceOperation.GET_ADDRESS));
showHideLink.setVisible(false);
setExpanded(true);
return;
}
Service<Address> addressService = cardApi.getAddressService(messageProperty);
handleCardOperation(addressService, getAddressButton, "Address", false, event -> {
EventManager.get().post(new DeviceAddressEvent(addressService.getValue()));
});
} catch(Exception e) {
log.error("Address Error: " + e.getMessage(), e);
setError("Address Error", e.getMessage());
getAddressButton.setDisable(false);
}
}
}
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
@@ -724,7 +975,7 @@ public class DevicePane extends TitledDescriptionPane {
}
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(true);
showHideLink.setVisible(!device.isCard());
List<ChildNumber> defaultDerivation = wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation();
setContent(getDerivationEntry(keyDerivation == null ? defaultDerivation : keyDerivation.getDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
@@ -743,6 +994,16 @@ public class DevicePane extends TitledDescriptionPane {
discoverKeystoresButton.setDefaultButton(defaultDevice);
discoverKeystoresButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_PRIVATE_KEY)) {
if(defaultDevice) {
getPrivateKeyButton.getStyleClass().add("default-button");
}
getPrivateKeyButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.GET_ADDRESS)) {
getAddressButton.setDefaultButton(defaultDevice);
getAddressButton.setVisible(true);
showHideLink.setVisible(false);
}
}
@@ -750,7 +1011,7 @@ public class DevicePane extends TitledDescriptionPane {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
derivationField.setDisable(keyDerivation != null);
derivationField.setDisable(device.isCard() || keyDerivation != null);
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
@@ -785,11 +1046,92 @@ public class DevicePane extends TitledDescriptionPane {
return contentBox;
}
private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
VBox initTypeBox = new VBox(5);
RadioButton automatic = new RadioButton("Automatic (Recommended)");
RadioButton advanced = new RadioButton("Advanced");
TextField entropy = new TextField();
entropy.setPromptText("Enter input for user entropy");
entropy.setDisable(true);
ToggleGroup toggleGroup = new ToggleGroup();
automatic.setToggleGroup(toggleGroup);
advanced.setToggleGroup(toggleGroup);
automatic.setSelected(true);
toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
entropy.setDisable(newValue == automatic);
});
initTypeBox.getChildren().addAll(automatic, advanced, entropy);
Button initializeButton = new Button("Initialize");
initializeButton.setDefaultButton(true);
initializeButton.setOnAction(event -> {
initializeButton.setDisable(true);
byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8));
Service<Void> cardInitializationService = cardApi.getInitializationService(chainCode, messageProperty);
cardInitializationService.setOnSucceeded(successEvent -> {
if(deviceOperation == DeviceOperation.IMPORT) {
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
} else if(deviceOperation == DeviceOperation.GET_ADDRESS) {
AppServices.showSuccessDialog("Card Reinitialized", "The card was successfully reinitialized.\n\nYou can now retrieve the new deposit address.");
}
operationButton.setDisable(false);
setDefaultStatus();
setExpanded(false);
});
cardInitializationService.setOnFailed(failEvent -> {
Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getCardPinEntry(operationButton));
operationButton.setDisable(false);
} else {
log.error("Error initializing card", failEvent.getSource().getException());
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
initializeButton.setDisable(false);
}
});
cardInitializationService.start();
});
HBox contentBox = new HBox(20);
contentBox.getChildren().addAll(initTypeBox, initializeButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
HBox.setHgrow(initTypeBox, Priority.ALWAYS);
return contentBox;
}
private Node getCardPinEntry(ButtonBase operationButton) {
VBox vBox = new VBox();
CustomPasswordField pinField = new ViewPasswordField();
pinField.setPromptText("PIN Code");
if(operationButton instanceof Button defaultButton) {
defaultButton.setDefaultButton(true);
}
pin.bind(pinField.textProperty());
HBox.setHgrow(pinField, Priority.ALWAYS);
Platform.runLater(pinField::requestFocus);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
return vBox;
}
public Device getDevice() {
return device;
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES;
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, GET_PRIVATE_KEY, GET_ADDRESS;
}
}
@@ -2,17 +2,24 @@ package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
public class DeviceSignDialog extends DeviceDialog<PSBT> {
private static final Logger log = LoggerFactory.getLogger(DeviceSignDialog.class);
private final Wallet wallet;
private final PSBT psbt;
public DeviceSignDialog(List<String> operationFingerprints, PSBT psbt) {
public DeviceSignDialog(Wallet wallet, List<String> operationFingerprints, PSBT psbt) {
super(operationFingerprints);
this.wallet = wallet;
this.psbt = psbt;
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -23,7 +30,7 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(psbt, device, defaultDevice);
return new DevicePane(wallet, psbt, device, defaultDevice);
}
@Subscribe
@@ -0,0 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.DeviceGetPrivateKeyEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceUnsealDialog extends DeviceDialog<DeviceUnsealDialog.DevicePrivateKey> {
public DeviceUnsealDialog(List<String> operationFingerprints) {
super(operationFingerprints);
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(DevicePane.DeviceOperation.GET_PRIVATE_KEY, device, defaultDevice);
}
@Subscribe
public void deviceGetPrivateKey(DeviceGetPrivateKeyEvent event) {
setResult(new DevicePrivateKey(event.getPrivateKey(), event.getScriptType()));
}
public record DevicePrivateKey(ECKey privateKey, ScriptType scriptType) {}
}
@@ -12,6 +12,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
@@ -30,7 +32,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class EntryCell extends TreeTableCell<Entry, Entry> {
public class EntryCell extends TreeTableCell<Entry, Entry> implements ConfirmationsListener {
private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
@@ -38,6 +40,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
private static EntryCell lastCell;
private IntegerProperty confirmationsProperty;
public EntryCell() {
super();
setAlignment(Pos.CENTER_LEFT);
@@ -120,7 +124,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(nodeEntry.getNode().toString());
@@ -189,6 +193,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
}
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
@@ -334,7 +353,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet();
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB) &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB || wallet.getKeystores().get(0).getWalletModel().isCard()) &&
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
}
@@ -568,7 +587,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems) {
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
@@ -591,7 +610,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(signVerifyMessage);
}
if(nodeEntry != null && !nodeEntry.getNode().getUnspentTransactionOutputs().isEmpty()) {
if(addUtxoItems && nodeEntry != null && !nodeEntry.getNode().getUnspentTransactionOutputs().isEmpty()) {
List<BlockTransactionHashIndex> utxos = nodeEntry.getNode().getUnspentTransactionOutputs().stream().collect(Collectors.toList());
MenuItem spendUtxos = new MenuItem("Spend UTXOs");
spendUtxos.setGraphic(getSendGlyph());
@@ -635,14 +654,6 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
copyHex.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(Utils.bytesToHex(address.getOutputScriptData()));
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
copyOutputDescriptor.setOnAction(AE -> {
hide();
@@ -651,7 +662,19 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
getItems().addAll(copyAddress, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem copyHex = new MenuItem("Copy Script Output Bytes");
copyHex.setOnAction(AE -> {
hide();
Script outputScript = nodeEntry.getWallet().getOutputScript(nodeEntry.getNode());
ClipboardContent content = new ClipboardContent();
content.putString(Utils.bytesToHex(outputScript.getProgram()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyHex);
}
}
}
@@ -722,13 +745,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(entry instanceof TransactionEntry) {
cell.getStyleClass().add("transaction-row");
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming");
transactionEntry.confirmationsProperty().addListener((observable, oldValue, newValue) -> {
if(!transactionEntry.isConfirming()) {
cell.getStyleClass().remove("confirming");
}
});
if(cell instanceof ConfirmationsListener confirmationsListener) {
if(transactionEntry.isConfirming()) {
cell.getStyleClass().add("confirming");
confirmationsListener.getConfirmationsProperty().bind(transactionEntry.confirmationsProperty());
} else {
confirmationsListener.getConfirmationsProperty().unbind();
}
}
} else if(entry instanceof NodeEntry) {
cell.getStyleClass().add("node-row");
@@ -34,17 +34,4 @@ public class FileKeystoreImportPane extends FileImportPane {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
}
private static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
if(wallet == null || requiredDerivation == null) {
return 0;
}
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
if(account < 0) {
account = 0;
}
return account;
}
}
@@ -28,12 +28,14 @@ public class FileWalletExportPane extends TitledDescriptionPane {
private final Wallet wallet;
private final WalletExport exporter;
private final boolean scannable;
private final boolean file;
public FileWalletExportPane(Wallet wallet, WalletExport exporter) {
super(exporter.getName(), "Wallet file export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
super(exporter.getName(), "Wallet export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.exporter = exporter;
this.scannable = exporter.isWalletExportScannable();
this.file = exporter.isWalletExportFile();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
@@ -41,7 +43,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
@Override
protected Control createButton() {
if(scannable) {
if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
@@ -61,6 +63,15 @@ public class FileWalletExportPane extends TitledDescriptionPane {
SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton;
} else if(scannable) {
Button showButton = new Button("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
exportQR();
});
return showButton;
} else {
Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
@@ -137,7 +148,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig) {
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
@@ -1,20 +1,23 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
public class KeystorePassphraseDialog extends Dialog<String> {
private final CustomPasswordField passphrase;
private final ObjectProperty<byte[]> masterFingerprint = new SimpleObjectProperty<>();
public KeystorePassphraseDialog(Keystore keystore) {
this(null, keystore);
@@ -45,10 +48,38 @@ public class KeystorePassphraseDialog extends Dialog<String> {
content.setPrefHeight(50);
content.getChildren().add(passphrase);
passphrase.textProperty().addListener((observable, oldValue, passphrase) -> {
masterFingerprint.set(getMasterFingerprint(keystore, passphrase));
});
HBox fingerprintBox = new HBox(10);
fingerprintBox.setAlignment(Pos.CENTER_LEFT);
Label fingerprintLabel = new Label("Master fingerprint:");
TextField fingerprintHex = new TextField();
fingerprintHex.setDisable(true);
fingerprintHex.setMaxWidth(80);
fingerprintHex.getStyleClass().addAll("fixed-width");
fingerprintHex.setStyle("-fx-opacity: 0.6");
masterFingerprint.addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
fingerprintHex.setText(Utils.bytesToHex(newValue));
}
});
LifeHashIcon lifeHashIcon = new LifeHashIcon();
lifeHashIcon.dataProperty().bind(masterFingerprint);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("All passphrases create valid wallets." +
"\nThe master fingerprint identifies the keystore and changes as the passphrase changes." +
"\n" + (confirm ? "Take a moment to identify it before proceeding." : "Make sure you recognise it before proceeding."));
fingerprintBox.getChildren().addAll(fingerprintLabel, fingerprintHex, lifeHashIcon, helpLabel);
content.getChildren().add(fingerprintBox);
masterFingerprint.set(getMasterFingerprint(keystore, ""));
Glyph warnGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
warnGlyph.getStyleClass().add("warn-icon");
warnGlyph.setFontSize(12);
Label warnLabel = new Label("A BIP39 passphrase is not a wallet password!", warnGlyph);
Label warnLabel = new Label((confirm ? "Note" : "Check") + " the master fingerprint before proceeding!", warnGlyph);
warnLabel.setGraphicTextGap(5);
content.getChildren().add(warnLabel);
@@ -57,4 +88,14 @@ public class KeystorePassphraseDialog extends Dialog<String> {
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? passphrase.getText() : null);
}
private byte[] getMasterFingerprint(Keystore keystore, String passphrase) {
try {
Keystore copyKeystore = keystore.copy();
copyKeystore.getSeed().setPassphrase(passphrase);
return copyKeystore.getExtendedMasterPrivateKey().getKey().getFingerprint();
} catch(MnemonicException e) {
throw new RuntimeException(e);
}
}
}
@@ -1,6 +1,10 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.event.Event;
import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTreeTableCell;
@@ -13,9 +17,11 @@ import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
class LabelCell extends TextFieldTreeTableCell<Entry, String> {
class LabelCell extends TextFieldTreeTableCell<Entry, String> implements ConfirmationsListener {
private static final Logger log = LoggerFactory.getLogger(LabelCell.class);
private IntegerProperty confirmationsProperty;
public LabelCell() {
super(new DefaultStringConverter());
getStyleClass().add("label-cell");
@@ -60,6 +66,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
super.commitEdit(label);
Platform.runLater(() -> getTreeTableView().requestFocus());
}
@Override
@@ -81,6 +88,21 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
}
@Override
public IntegerProperty getConfirmationsProperty() {
if(confirmationsProperty == null) {
confirmationsProperty = new SimpleIntegerProperty();
confirmationsProperty.addListener((observable, oldValue, newValue) -> {
if(newValue.intValue() >= BlockTransactionHash.BLOCKS_TO_CONFIRM) {
getStyleClass().remove("confirming");
confirmationsProperty.unbind();
}
});
}
return confirmationsProperty;
}
private static class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label");
@@ -0,0 +1,70 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.sparrow.io.ImageUtils;
import com.sparrowwallet.toucan.LifeHash;
import com.sparrowwallet.toucan.LifeHashVersion;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.Group;
import javafx.scene.image.Image;
import javafx.scene.paint.Color;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Rectangle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.image.BufferedImage;
import java.util.Arrays;
public class LifeHashIcon extends Group {
private static final Logger log = LoggerFactory.getLogger(LifeHashIcon.class);
private static final int SIZE = 24;
private final ObjectProperty<byte[]> dataProperty = new SimpleObjectProperty<>(null);
public LifeHashIcon() {
super();
dataProperty.addListener((observable, oldValue, data) -> {
if(data == null) {
getChildren().clear();
} else if(oldValue == null || !Arrays.equals(oldValue, data)) {
LifeHash.Image lifeHashImage = LifeHash.makeFromData(data, LifeHashVersion.VERSION2, 1, false);
BufferedImage bufferedImage = LifeHash.getBufferedImage(lifeHashImage);
BufferedImage resizedImage = ImageUtils.resizeToImage(bufferedImage, SIZE, SIZE);
Image image = SwingFXUtils.toFXImage(resizedImage, null);
setImage(image);
}
});
}
private void setImage(Image image) {
getChildren().clear();
Rectangle rectangle = new Rectangle(SIZE, SIZE);
rectangle.setArcWidth(6);
rectangle.setArcHeight(6);
rectangle.setFill(new ImagePattern(image));
rectangle.setStroke(Color.rgb(65, 72, 77));
rectangle.setStrokeWidth(1.0);
getChildren().add(rectangle);
}
public byte[] getData() {
return dataProperty.get();
}
public ObjectProperty<byte[]> dataProperty() {
return dataProperty;
}
public void setData(byte[] data) {
this.dataProperty.set(data);
}
public void setHex(String hex) {
setData(hex == null ? null : Utils.hexToBytes(hex));
}
}
@@ -8,10 +8,7 @@ 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.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
@@ -269,8 +266,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore");
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB && !wallet.getKeystores().get(0).getWalletModel().isCard()) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a connected keystore");
}
}
@@ -295,7 +292,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private boolean isValidAddress() {
try {
Address address = getAddress();
return address.getScriptType() != ScriptType.P2TR && address.getScriptType().isAllowed(PolicyType.SINGLE);
return address.getScriptType() != ScriptType.P2TR && (address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH);
} catch (InvalidAddressException e) {
return false;
}
@@ -319,8 +316,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
} else {
signUnencryptedKeystore(signingWallet);
}
} else if(signingWallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(signingWallet);
} else if(signingWallet.containsSource(KeystoreSource.HW_USB) || wallet.getKeystores().get(0).getWalletModel().isCard()) {
signDeviceKeystore(signingWallet);
}
}
@@ -339,10 +336,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
}
private void signUsbKeystore(Wallet usbWallet) {
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation);
private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
signature.clear();
@@ -2,8 +2,11 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.PayNymImageLoadedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
@@ -128,6 +131,7 @@ public class PayNymAvatar extends StackPane {
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false);
paymentCodeCache.put(cacheId, image);
Platform.runLater(() -> EventManager.get().post(new PayNymImageLoadedEvent(paymentCode, image)));
return image;
} catch(Exception e) {
log.debug("Error loading PayNym avatar", e);
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.base.Throwables;
import com.google.common.io.Files;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
@@ -14,7 +15,10 @@ import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
@@ -42,7 +46,8 @@ import tornadofx.control.Form;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -96,6 +101,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
HBox.setHgrow(key, Priority.ALWAYS);
keyField.getInputs().add(keyBox);
if(CardApi.isReaderAvailable()) {
VBox cardButtonBox = new VBox(5);
Button cardKey = new Button("", getGlyph(FontAwesome5.Glyph.WIFI));
cardKey.setOnAction(event -> unsealPrivateKey());
cardButtonBox.getChildren().add(cardKey);
keyBox.getChildren().add(cardButtonBox);
}
Field keyScriptTypeField = new Field();
keyScriptTypeField.setText("Script Type:");
keyScriptType = new ComboBox<>();
@@ -279,6 +292,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
}
}
private void unsealPrivateKey() {
DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList());
Optional<DeviceUnsealDialog.DevicePrivateKey> optPrivateKey = deviceUnsealDialog.showAndWait();
if(optPrivateKey.isPresent()) {
DeviceUnsealDialog.DevicePrivateKey devicePrivateKey = optPrivateKey.get();
key.setText(devicePrivateKey.privateKey().getPrivateKeyEncoded().toBase58());
keyScriptType.setValue(devicePrivateKey.scriptType());
}
}
private void createTransaction() {
try {
DumpedPrivateKey privateKey = getPrivateKey();
@@ -286,14 +309,30 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
Address fromAddress = scriptType.getAddress(privateKey.getKey());
Address destAddress = getToAddress();
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress);
Date since = null;
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
WalletBirthDateDialog addressScanDateDialog = new WalletBirthDateDialog(null, true);
Optional<Date> optSince = addressScanDateDialog.showAndWait();
if(optSince.isPresent()) {
since = optSince.get();
}
}
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress, since);
addressUtxosService.setOnSucceeded(successEvent -> {
createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress);
});
addressUtxosService.setOnFailed(failedEvent -> {
Throwable rootCause = Throwables.getRootCause(failedEvent.getSource().getException());
log.error("Error retrieving outputs for address " + fromAddress, failedEvent.getSource().getException());
AppServices.showErrorDialog("Error retrieving outputs for address", failedEvent.getSource().getException().getMessage());
AppServices.showErrorDialog("Error retrieving outputs for address", rootCause.getMessage());
});
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
ServiceProgressDialog serviceProgressDialog = new ServiceProgressDialog("Address Scan", "Scanning address for transactions...", "/image/sparrow.png", addressUtxosService);
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
}
addressUtxosService.start();
} catch(Exception e) {
log.error("Error creating sweep transaction", e);
@@ -321,8 +360,13 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
feeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + 1;
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
return;
}
}
Transaction transaction = new Transaction();
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.control;
public enum QRDensity {
NORMAL("Normal", 250),
LOW("Low", 80);
private final String name;
private final int maxFragmentLength;
QRDensity(String name, int maxFragmentLength) {
this.name = name;
this.maxFragmentLength = maxFragmentLength;
}
public String getName() {
return name;
}
public int getMaxFragmentLength() {
return maxFragmentLength;
}
}
@@ -9,6 +9,7 @@ import com.sparrowwallet.hummingbird.LegacyUREncoder;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
@@ -28,13 +29,13 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
import java.util.Optional;
@SuppressWarnings("deprecation")
public class QRDisplayDialog extends Dialog<UR> {
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int MAX_FRAGMENT_LENGTH = 250;
private static final int ANIMATION_PERIOD_MILLIS = 200;
@@ -42,17 +43,20 @@ public class QRDisplayDialog extends Dialog<UR> {
private static final int QR_HEIGHT = 480;
private final UR ur;
private final UREncoder encoder;
private UREncoder encoder;
private final ImageView qrImageView;
private AnimateQRService animateQRService;
private String currentPart;
private boolean addLegacyEncodingOption;
private boolean useLegacyEncoding;
private String[] legacyParts;
private int legacyPartIndex;
private static boolean initialDensityChange;
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
this(UR.fromBytes(type, data), addLegacyEncodingOption);
}
@@ -63,7 +67,8 @@ public class QRDisplayDialog extends Dialog<UR> {
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) {
this.ur = ur;
this.encoder = new UREncoder(ur, MAX_FRAGMENT_LENGTH, MIN_FRAGMENT_LENGTH, 0);
this.addLegacyEncodingOption = addLegacyEncodingOption;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
@@ -88,6 +93,9 @@ public class QRDisplayDialog extends Dialog<UR> {
if(addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
} else {
final ButtonType densityButtonType = new javafx.scene.control.ButtonType("Change Density", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(densityButtonType);
}
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
@@ -201,6 +209,20 @@ public class QRDisplayDialog extends Dialog<UR> {
}
}
private void changeQRDensity() {
if(animateQRService != null) {
animateQRService.cancel();
}
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
nextPart();
if(encoder.isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
createAnimateQRService();
}
}
private class AnimateQRService extends ScheduledService<Boolean> {
@Override
protected Task<Boolean> createTask() {
@@ -220,18 +242,44 @@ public class QRDisplayDialog extends Dialog<UR> {
@Override
protected Node createButton(ButtonType buttonType) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton legacy = new ToggleButton(buttonType.getText());
legacy.setGraphicTextGap(5);
setLegacyGraphic(legacy, false);
if(addLegacyEncodingOption) {
ToggleButton legacy = new ToggleButton(buttonType.getText());
legacy.setGraphicTextGap(5);
setLegacyGraphic(legacy, false);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(legacy, buttonData);
legacy.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseLegacyEncoding(newValue);
setLegacyGraphic(legacy, newValue);
});
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(legacy, buttonData);
legacy.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseLegacyEncoding(newValue);
setLegacyGraphic(legacy, newValue);
});
return legacy;
return legacy;
} else {
Button density = new Button(buttonType.getText());
density.setPrefWidth(160);
density.setGraphicTextGap(5);
updateDensityButton(density);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(density, buttonData);
density.setOnAction(event -> {
if(!initialDensityChange && !encoder.isSinglePart()) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
initialDensityChange = true;
} else {
return;
}
}
Config.get().setQrDensity(Config.get().getQrDensity() == QRDensity.NORMAL ? QRDensity.LOW : QRDensity.NORMAL);
updateDensityButton(density);
changeQRDensity();
});
return density;
}
}
return super.createButton(buttonType);
@@ -244,6 +292,15 @@ public class QRDisplayDialog extends Dialog<UR> {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
}
}
private void updateDensityButton(Button density) {
density.setText(Config.get().getQrDensity() == QRDensity.NORMAL ? "Decrease Density" : "Increase Density");
if(Config.get().getQrDensity() == QRDensity.NORMAL) {
density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_PLUS));
} else {
density.setGraphic(getGlyph(FontAwesome5.Glyph.MAGNIFYING_GLASS_MINUS));
}
}
}
protected static Glyph getGlyph(FontAwesome5.Glyph glyphName) {
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.geometry.Insets;
@@ -106,13 +108,17 @@ public class TitledDescriptionPane extends TitledPane {
protected void setDescription(String text) {
descriptionLabel.getStyleClass().remove("description-error");
descriptionLabel.getStyleClass().add("description-label");
if(!descriptionLabel.getStyleClass().contains("description-label")) {
descriptionLabel.getStyleClass().add("description-label");
}
descriptionLabel.setText(text);
}
protected void setError(String title, String detail) {
descriptionLabel.getStyleClass().remove("description-label");
descriptionLabel.getStyleClass().add("description-error");
if(!descriptionLabel.getStyleClass().contains("description-error")) {
descriptionLabel.getStyleClass().add("description-error");
}
descriptionLabel.setText(title);
if(detail != null && !detail.isEmpty()) {
setContent(getContentBox(detail));
@@ -159,4 +165,17 @@ public class TitledDescriptionPane extends TitledPane {
}
});
}
protected static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
if(wallet == null || requiredDerivation == null) {
return 0;
}
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
if(account < 0) {
account = 0;
}
return account;
}
}
@@ -63,6 +63,8 @@ public class TrayManager {
miStage.setFont(Font.decode(null).deriveFont(Font.BOLD));
miStage.addActionListener(e -> Platform.runLater(() -> {
stage.show();
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
EventQueue.invokeLater(() -> {
popupMenu.remove(miStage);
@@ -21,12 +21,12 @@ import java.util.Date;
public class WalletBirthDateDialog extends Dialog<Date> {
private final DatePicker birthDatePicker;
public WalletBirthDateDialog(Date birthDate) {
public WalletBirthDateDialog(Date birthDate, boolean singleAddress) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first wallet transaction:");
setTitle(singleAddress ? "Address Scan Start Date" : "Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first " + (singleAddress ? "" : "wallet") + " transaction:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(420);
@@ -58,7 +58,7 @@ public class WalletBirthDateDialog extends Dialog<Date> {
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType(singleAddress ? "Scan Address" : "Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> birthDatePicker.getValue() == null, birthDatePicker.valueProperty());
@@ -42,9 +42,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow());
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -0,0 +1,215 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImageUtils;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Pos;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle;
import org.controlsfx.glyphfont.Glyph;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
public class WalletIcon extends StackPane {
public static final String PROTOCOL = "walleticon";
private static final String QUERY = "icon";
public static final int WIDTH = 15;
public static final int HEIGHT = 15;
public static final int SAVE_WIDTH = WIDTH * 2;
public static final int SAVE_HEIGHT = HEIGHT * 2;
private final Storage storage;
private final ObjectProperty<Wallet> walletProperty = new SimpleObjectProperty<>();
private final Keystore keystore;
private final Glyph fallbackIcon;
public WalletIcon(Storage storage, Wallet wallet) {
this(storage, wallet, null, null);
}
public WalletIcon(Storage storage, Wallet wallet, Keystore keystore, Glyph fallbackIcon) {
super();
this.storage = storage;
this.keystore = keystore;
this.fallbackIcon = fallbackIcon;
setPrefSize(WIDTH, HEIGHT);
walletProperty.addListener((observable, oldValue, newValue) -> {
refresh();
});
walletProperty.set(wallet);
}
public void refresh() {
Wallet wallet = getWallet();
getChildren().clear();
if(wallet.getWalletConfig() != null && wallet.getWalletConfig().getIconData() != null) {
String walletId = storage.getWalletId(wallet);
if(AppServices.get().getWallet(walletId) != null) {
addWalletIcon(walletId);
} else {
Platform.runLater(() -> addWalletIcon(walletId));
}
} else if(this.keystore != null || wallet.getKeystores().size() == 1) {
Keystore keystore = this.keystore == null ? wallet.getKeystores().get(0) : this.keystore;
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getSource() == KeystoreSource.HW_AIRGAPPED) {
WalletModel walletModel = keystore.getWalletModel();
SVGImage svgImage;
if(Config.get().getTheme() == Theme.DARK) {
svgImage = loadSVGImage("/image/" + walletModel.getType() + "-icon-invert.svg");
} else {
svgImage = loadSVGImage("/image/" + walletModel.getType() + "-icon.svg");
}
if(svgImage != null) {
getChildren().add(svgImage);
return;
}
Image image = null;
if(Config.get().getTheme() == Theme.DARK) {
image = loadImage("image/" + walletModel.getType() + "-icon-invert.png");
}
if(image == null) {
image = loadImage("image/" + walletModel.getType() + "-icon.png");
}
if(image == null) {
image = loadImage("image/" + walletModel.getType() + ".png");
}
if(image != null && !image.isError()) {
ImageView imageView = new ImageView(image);
getChildren().add(imageView);
}
}
}
if(getChildren().isEmpty()) {
Glyph glyph = fallbackIcon;
if(glyph == null) {
glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
}
glyph.setFontSize(10.0);
getChildren().add(glyph);
}
}
private SVGImage loadSVGImage(String imageName) {
try {
URL url = AppServices.class.getResource(imageName);
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
//ignore
}
return null;
}
private Image loadImage(String imageName) {
try {
return new Image(imageName, 15, 15, true, true);
} catch(Exception e) {
//ignore
}
return null;
}
private void addWalletIcon(String walletId) {
Image image = new Image(PROTOCOL + ":" + walletId + "?" + QUERY, WIDTH, HEIGHT, true, false);
getChildren().clear();
Circle circle = new Circle(getPrefWidth() / 2,getPrefHeight() / 2,getPrefWidth() / 2);
circle.setFill(new ImagePattern(image));
getChildren().add(circle);
}
public boolean addFailure() {
if(getChildren().stream().noneMatch(node -> node.getStyleClass().contains("failure"))) {
Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
failGlyph.setFontSize(10);
failGlyph.getStyleClass().add("failure");
getChildren().add(failGlyph);
StackPane.setAlignment(failGlyph, Pos.TOP_RIGHT);
failGlyph.setTranslateX(5);
failGlyph.setTranslateY(-4);
return true;
}
return false;
}
public void removeFailure() {
getChildren().removeIf(node -> node.getStyleClass().contains("failure"));
}
public Wallet getWallet() {
return walletProperty.get();
}
public ObjectProperty<Wallet> walletProperty() {
return walletProperty;
}
public void setWallet(Wallet wallet) {
this.walletProperty.set(wallet);
}
public static class WalletIconStreamHandler extends URLStreamHandler {
@Override
protected URLConnection openConnection(URL url) throws IOException {
return new URLConnection(url) {
@Override
public void connect() throws IOException {
//Nothing required
}
@Override
public InputStream getInputStream() throws IOException {
String walletId = url.getPath();
String query = url.getQuery();
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet == null) {
throw new IOException("Cannot find wallet for wallet id " + walletId);
}
if(query.startsWith(QUERY)) {
if(wallet.getWalletConfig() == null || wallet.getWalletConfig().getIconData() == null) {
throw new IOException("No icon data for " + walletId);
}
ByteArrayInputStream bais = new ByteArrayInputStream(wallet.getWalletConfig().getIconData());
if(query.endsWith("@2x")) {
return bais;
} else {
return ImageUtils.resize(bais, WalletIcon.WIDTH, WalletIcon.HEIGHT);
}
}
throw new MalformedURLException("Cannot load url " + url);
}
};
}
}
}
@@ -9,6 +9,7 @@ import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
@@ -16,6 +17,7 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -24,7 +26,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
private final Accordion importAccordion;
private final Button scanButton;
public WalletImportDialog() {
public WalletImportDialog(List<WalletForm> selectedWalletForms) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -57,7 +59,10 @@ public class WalletImportDialog extends Dialog<Wallet> {
}
}
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
List<WalletImport> walletImporters = new ArrayList<>(List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()));
if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms));
}
for(WalletImport importer : walletImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.sparrow.net.TlsServerException;
public class ConnectionFailedEvent {
@@ -20,6 +21,12 @@ public class ConnectionFailedEvent {
Throwable cause = (exception.getCause() != null ? exception.getCause() : exception);
cause = (cause.getCause() != null ? cause.getCause() : cause);
if(cause instanceof JsonRpcException jsonRpcException && jsonRpcException.getErrorMessage() != null) {
return jsonRpcException.getErrorMessage().getMessage() +
(jsonRpcException.getErrorMessage().getData() != null ? " (" + jsonRpcException.getErrorMessage().getData().asText() + ")" : "");
}
String message = splitCamelCase(cause.getClass().getSimpleName().replace("Exception", "Error"));
return message + " (" + cause.getMessage() + ")";
}
@@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CormorantPruneStatusEvent extends CormorantStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd");
private final Wallet wallet;
private final Date scanDate;
private final Date pruneDate;
private final boolean legacyWalletExists;
public CormorantPruneStatusEvent(String status, Wallet wallet, Date scanDate, Date pruneDate, boolean legacyWalletExists) {
super(status);
this.wallet = wallet;
this.scanDate = scanDate;
this.pruneDate = pruneDate;
this.legacyWalletExists = legacyWalletExists;
}
@Override
public boolean isFor(Wallet wallet) {
return this.wallet == wallet;
}
public Wallet getWallet() {
return wallet;
}
public Date getScanDate() {
return scanDate;
}
public String getScanDateAsString() {
return DATE_FORMAT.format(scanDate);
}
public Date getPruneDate() {
return pruneDate;
}
public String getPruneDateAsString() {
return DATE_FORMAT.format(pruneDate);
}
public boolean legacyWalletExists() {
return legacyWalletExists;
}
}
@@ -0,0 +1,48 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.time.Duration;
import java.util.Set;
public class CormorantScanStatusEvent extends CormorantStatusEvent {
private final Set<Wallet> scanningWallets;
private final int progress;
private final Duration remainingDuration;
public CormorantScanStatusEvent(String status, Set<Wallet> scanningWallets, int progress, Duration remainingDuration) {
super(status);
this.scanningWallets = scanningWallets;
this.progress = progress;
this.remainingDuration = remainingDuration;
}
@Override
public boolean isFor(Wallet wallet) {
return scanningWallets.contains(wallet);
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Duration getRemaining() {
return remainingDuration;
}
public String getRemainingAsString() {
if(remainingDuration != null) {
if(progress < 30) {
return Math.round((double)remainingDuration.toSeconds() / 60) + "m";
} else {
return remainingDuration.toMinutesPart() + "m " + remainingDuration.toSecondsPart() + "s";
}
}
return "";
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public abstract class CormorantStatusEvent {
private final String status;
public CormorantStatusEvent(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public abstract boolean isFor(Wallet wallet);
}
@@ -0,0 +1,41 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class CormorantSyncStatusEvent extends CormorantStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm");
private final int progress;
private final Date tip;
public CormorantSyncStatusEvent(String status, int progress, Date tip) {
super(status);
this.progress = progress;
this.tip = tip;
}
@Override
public boolean isFor(Wallet wallet) {
return true;
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Date getTip() {
return tip;
}
public String getTipAsString() {
return tip == null ? "" : DATE_FORMAT.format(tip);
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.address.Address;
public class DeviceAddressEvent {
private final Address address;
public DeviceAddressEvent(Address address) {
this.address = address;
}
public Address getAddress() {
return address;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
public class DeviceGetPrivateKeyEvent {
private final ECKey privateKey;
private final ScriptType scriptType;
public DeviceGetPrivateKeyEvent(ECKey privateKey, ScriptType scriptType) {
this.privateKey = privateKey;
this.scriptType = scriptType;
}
public ECKey getPrivateKey() {
return privateKey;
}
public ScriptType getScriptType() {
return scriptType;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import javafx.scene.image.Image;
public class PayNymImageLoadedEvent {
private final PaymentCode paymentCode;
private final Image image;
public PayNymImageLoadedEvent(PaymentCode paymentCode, Image image) {
this.paymentCode = paymentCode;
this.image = image;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
public Image getImage() {
return image;
}
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletConfigChangedEvent extends WalletChangedEvent {
public WalletConfigChangedEvent(Wallet wallet) {
super(wallet);
}
}
@@ -11,22 +11,29 @@ import java.util.*;
public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
//Contains the changed entry mapped to the entry that changed it, if changed recursively (otherwise null)
private final Map<Entry, Entry> entrySourceMap;
private final boolean propagate;
public WalletEntryLabelsChangedEvent(Wallet wallet, Entry entry) {
this(wallet, List.of(entry));
}
public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries) {
this(wallet, entries, true);
}
public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries, boolean propagate) {
super(wallet);
this.entrySourceMap = new LinkedHashMap<>();
for(Entry entry : entries) {
entrySourceMap.put(entry, null);
}
this.propagate = propagate;
}
public WalletEntryLabelsChangedEvent(Wallet wallet, Map<Entry, Entry> entrySourceMap) {
super(wallet);
this.entrySourceMap = entrySourceMap;
this.propagate = true;
}
public Collection<Entry> getEntries() {
@@ -36,4 +43,8 @@ public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
public Entry getSource(Entry entry) {
return entrySourceMap.get(entry);
}
public boolean propagate() {
return propagate;
}
}
@@ -37,7 +37,7 @@ public class WalletNodeHistoryChangedEvent {
Wallet notificationWallet = wallet.getNotificationWallet();
if(notificationWallet != null) {
WalletNode notificationNode = notificationWallet.getNode(KeyPurpose.NOTIFICATION);
if(ElectrumServer.getScriptHash(notificationWallet, notificationNode).equals(scriptHash)) {
if(ElectrumServer.getScriptHash(notificationNode).equals(scriptHash)) {
return notificationNode;
}
}
@@ -59,7 +59,7 @@ public class WalletNodeHistoryChangedEvent {
private WalletNode getWalletNode(Wallet wallet, KeyPurpose keyPurpose) {
WalletNode purposeNode = wallet.getNode(keyPurpose);
for(WalletNode addressNode : new ArrayList<>(purposeNode.getChildren())) {
if(ElectrumServer.getScriptHash(wallet, addressNode).equals(scriptHash)) {
if(ElectrumServer.getScriptHash(addressNode).equals(scriptHash)) {
return addressNode;
}
}
@@ -49,6 +49,8 @@ public class FontAwesome5 extends GlyphFont {
LINK('\uf0c1'),
LOCK('\uf023'),
LOCK_OPEN('\uf3c1'),
MAGNIFYING_GLASS_PLUS('\uf00e'),
MAGNIFYING_GLASS_MINUS('\uf010'),
MINUS_CIRCLE('\uf056'),
PEN_FANCY('\uf5ac'),
PLUS('\uf067'),
@@ -80,7 +82,8 @@ public class FontAwesome5 extends GlyphFont {
USER_PLUS('\uf234'),
USER_SLASH('\uf506'),
WALLET('\uf555'),
WEIGHT('\uf496');
WEIGHT('\uf496'),
WIFI('\uf1eb');
private final char ch;
@@ -0,0 +1,161 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import javax.smartcardio.CardTerminals;
import javax.smartcardio.TerminalFactory;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
public abstract class CardApi {
private static final Logger log = LoggerFactory.getLogger(CardApi.class);
private static File[] LINUX_PCSC_LIBS = new File[] {
new File("/usr/lib/libpcsclite.so.1"),
new File("/usr/local/lib/libpcsclite.so.1"),
new File("/lib/x86_64-linux-gnu/libpcsclite.so.1"),
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1")};
private static boolean initialized;
public static List<WalletModel> getConnectedCards() throws CardException {
try {
CkCardApi ckCardApi = new CkCardApi(null, null);
return List.of(ckCardApi.getCardType());
} catch(Exception e) {
//ignore
}
return Collections.emptyList();
}
public static CardApi getCardApi(WalletModel walletModel, String pin) throws CardException {
if(walletModel == WalletModel.TAPSIGNER || walletModel == WalletModel.SATSCARD) {
return new CkCardApi(walletModel, pin);
}
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
}
public abstract boolean isInitialized() throws CardException;
public abstract void initialize(int slot, byte[] entropy) throws CardException;
public abstract WalletModel getCardType() throws CardException;
public abstract ScriptType getDefaultScriptType();
public abstract int getCurrentSlot() throws CardException;
public abstract Service<Void> getAuthDelayService() throws CardException;
public abstract boolean requiresBackup() throws CardException;
public abstract Service<String> getBackupService();
public abstract boolean changePin(String newPin) throws CardException;
public abstract Keystore getKeystore() throws CardException;
public abstract Service<Void> getInitializationService(byte[] entropy, StringProperty messageProperty);
public abstract Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty);
public abstract Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty);
public abstract Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty);
public abstract Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty);
public abstract Service<Address> getAddressService(StringProperty messageProperty);
public abstract void disconnect();
public static boolean isReaderAvailable() {
setLibrary();
try {
TerminalFactory tf = TerminalFactory.getDefault();
return !tf.terminals().list().isEmpty();
} catch(Exception e) {
Throwable cause = Throwables.getRootCause(e);
if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) {
recoverNoService();
} else {
log.error("Error detecting card terminals", e);
}
}
return false;
}
private static void recoverNoService() {
try {
Class<?> pcscterminal = Class.forName("sun.security.smartcardio.PCSCTerminals");
Field contextId = pcscterminal.getDeclaredField("contextId");
contextId.setAccessible(true);
if(contextId.getLong(pcscterminal) != 0L)
{
// First get a new context value
Class<?> pcsc = Class.forName("sun.security.smartcardio.PCSC");
Method SCardEstablishContext = pcsc.getDeclaredMethod(
"SCardEstablishContext",
Integer.TYPE);
SCardEstablishContext.setAccessible(true);
Field SCARD_SCOPE_USER = pcsc.getDeclaredField("SCARD_SCOPE_USER");
SCARD_SCOPE_USER.setAccessible(true);
long newId = ((Long)SCardEstablishContext.invoke(pcsc,
new Object[] { SCARD_SCOPE_USER.getInt(pcsc) }
));
contextId.setLong(pcscterminal, newId);
// Then clear the terminals in cache
TerminalFactory factory = TerminalFactory.getDefault();
CardTerminals terminals = factory.terminals();
Field fieldTerminals = pcscterminal.getDeclaredField("terminals");
fieldTerminals.setAccessible(true);
Class<?> classMap = Class.forName("java.util.Map");
Method clearMap = classMap.getDeclaredMethod("clear");
clearMap.invoke(fieldTerminals.get(terminals));
}
} catch(Exception e) {
log.error("Failed to recover card service", e);
}
}
private static void setLibrary() {
if(!initialized && Platform.getCurrent() == Platform.UNIX) {
for(File lib : LINUX_PCSC_LIBS) {
if(lib.exists()) {
System.setProperty("sun.security.smartcardio.library", lib.getAbsolutePath());
break;
}
}
}
initialized = true;
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.io;
import javax.smartcardio.CardException;
public class CardAuthorizationException extends CardException {
public CardAuthorizationException(String message) {
super(message);
}
public CardAuthorizationException(Throwable cause) {
super(cause);
}
public CardAuthorizationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,10 @@
package com.sparrowwallet.sparrow.io;
import javafx.beans.property.StringProperty;
import javax.smartcardio.CardException;
public interface CardImport extends ImportExport {
boolean isInitialized() throws CardException;
void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException;
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.io;
import javax.smartcardio.CardException;
public class CardSignFailedException extends CardException {
public CardSignFailedException(String message) {
super(message);
}
public CardSignFailedException(Throwable cause) {
super(cause);
}
public CardSignFailedException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.io;
import javax.smartcardio.CardException;
public class CardUnluckyNumberException extends CardException {
public CardUnluckyNumberException(String message) {
super(message);
}
public CardUnluckyNumberException(Throwable cause) {
super(cause);
}
public CardUnluckyNumberException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.control.QRDensity;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
@@ -53,6 +54,7 @@ public class Config {
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private File hwi;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private Boolean hdCapture;
private String webcamDevice;
private ServerType serverType;
@@ -62,11 +64,13 @@ public class Config {
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private boolean useLegacyCoreWallet;
private Server electrumServer;
private List<Server> recentElectrumServers;
private File electrumServerCert;
private boolean useProxy;
private String proxyServer;
private boolean autoSwitchProxy = true;
private int maxServerTimeout = DEFAULT_MAX_TIMEOUT;
private int maxPageSize = DEFAULT_PAGE_SIZE;
private boolean usePayNym;
@@ -354,6 +358,15 @@ public class Config {
return enumerateHwPeriod;
}
public QRDensity getQrDensity() {
return qrDensity == null ? QRDensity.NORMAL : qrDensity;
}
public void setQrDensity(QRDensity qrDensity) {
this.qrDensity = qrDensity;
flush();
}
public Boolean getHdCapture() {
return hdCapture;
}
@@ -501,6 +514,15 @@ public class Config {
flush();
}
public boolean isUseLegacyCoreWallet() {
return useLegacyCoreWallet;
}
public void setUseLegacyCoreWallet(boolean useLegacyCoreWallet) {
this.useLegacyCoreWallet = useLegacyCoreWallet;
flush();
}
public Server getElectrumServer() {
return electrumServer;
}
@@ -584,6 +606,15 @@ public class Config {
flush();
}
public boolean isAutoSwitchProxy() {
return autoSwitchProxy;
}
public void setAutoSwitchProxy(boolean autoSwitchProxy) {
this.autoSwitchProxy = autoSwitchProxy;
flush();
}
public int getMaxServerTimeout() {
return maxServerTimeout;
}
@@ -7,7 +7,8 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
import java.util.ArrayList;
import java.util.List;
public class Descriptor implements WalletImport, WalletExport {
@@ -24,10 +25,29 @@ public class Descriptor implements WalletImport, WalletExport {
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream));
bufferedWriter.write("# Receive and change descriptor (BIP389):");
bufferedWriter.newLine();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null);
String outputDescriptorString = outputDescriptor.toString(true);
outputStream.write(outputDescriptorString.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
bufferedWriter.write(outputDescriptor.toString(true));
bufferedWriter.newLine();
bufferedWriter.newLine();
bufferedWriter.newLine();
bufferedWriter.write("# Receive descriptor (Bitcoin Core):");
bufferedWriter.newLine();
OutputDescriptor receiveDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE, null);
bufferedWriter.write(receiveDescriptor.toString(true));
bufferedWriter.newLine();
bufferedWriter.newLine();
bufferedWriter.write("# Change descriptor (Bitcoin Core):");
bufferedWriter.newLine();
OutputDescriptor changeDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.CHANGE, null);
bufferedWriter.write(changeDescriptor.toString(true));
bufferedWriter.newLine();
bufferedWriter.flush();
} catch(Exception e) {
throw new ExportException("Error writing output descriptor", e);
}
@@ -77,14 +97,43 @@ public class Descriptor implements WalletImport, WalletExport {
//ignore
}
String outputDescriptor = new BufferedReader(new InputStreamReader(secondClone, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor.trim());
return descriptor.toWallet();
List<String> paragraphs = getParagraphs(secondClone);
for(String paragraph : paragraphs) {
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(paragraph);
return descriptor.toWallet();
}
throw new ImportException("Could not find an output descriptor in the file");
} catch(Exception e) {
throw new ImportException("Error importing output descriptor", e);
}
}
private static List<String> getParagraphs(InputStream inputStream) {
List<String> paragraphs = new ArrayList<>();
StringBuilder paragraph = new StringBuilder();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
for(String line : reader.lines().map(String::trim).toArray(String[]::new)) {
if(line.isEmpty()) {
if(paragraph.length() > 0) {
paragraphs.add(paragraph.toString());
paragraph.setLength(0);
}
} else if(line.startsWith("#")) {
continue;
} else {
paragraph.append(line);
}
}
if(paragraph.length() > 0) {
paragraphs.add(paragraph.toString());
}
return paragraphs;
}
@Override
public boolean isWalletImportScannable() {
return false;
@@ -11,6 +11,8 @@ public class Device {
private Boolean needsPinSent;
private Boolean needsPassphraseSent;
private String fingerprint;
private boolean card;
private String[][] warnings;
private String error;
public String getType() {
@@ -69,6 +71,28 @@ public class Device {
this.fingerprint = fingerprint;
}
public boolean isCard() {
return card;
}
public void setCard(boolean card) {
this.card = card;
}
public boolean containsWarning(String warning) {
if(warnings != null) {
for(String[] warns : warnings) {
for(String warn : warns) {
if(warn.contains(warning)) {
return true;
}
}
}
}
return false;
}
public String getError() {
return error;
}
@@ -17,6 +17,8 @@ import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import javax.smartcardio.CardNotPresentException;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
@@ -34,27 +36,42 @@ public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.1.1";
private static final String HWI_VERSION = "2.2.0";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
public List<Device> enumerate(String passphrase) throws ImportException {
List<Device> devices = new ArrayList<>();
devices.addAll(enumerateUsb(passphrase));
devices.addAll(enumerateCard());
return devices;
}
private List<Device> enumerateUsb(String passphrase) throws ImportException {
String output = null;
try {
List<String> command;
if(passphrase != null && !passphrase.isEmpty()) {
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), "--password", escape(passphrase), Command.ENUMERATE.toString());
if(passphrase != null) {
command = new ArrayList<>(List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), "--password", escape(passphrase), Command.ENUMERATE.toString()));
} else {
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), Command.ENUMERATE.toString());
command = new ArrayList<>(List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), Command.ENUMERATE.toString()));
}
addChainType(command, true);
isPromptActive = true;
output = execute(command);
Device[] devices = getGson().fromJson(output, Device[].class);
if(devices == null) {
throw new ImportException("Error scanning, check devices are ready");
}
//Restore previous (pre v2.2.0) behaviour for Trezor One - don't default to an empty passphrase if one is not supplied
Arrays.stream(devices).filter(device -> device.containsWarning("Using default passphrase of the empty string")).forEach(device -> {
device.setFingerprint(null);
device.setNeedsPassphraseSent(true);
device.setError("Passphrase needs to be specified before the fingerprint information can be retrieved");
});
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
log.error("Error executing " + HWI_VERSION_DIR, e);
@@ -67,7 +84,7 @@ public class Hwi {
throw new ImportException(error.getAsString());
} catch(Exception ex) {
log.error("Error parsing JSON: " + output, e);
throw new ImportException("Error parsing JSON: " + output, e);
throw new ImportException("Error scanning" + (output.isEmpty() ? ", check devices are ready" : ": " + output), e);
}
}
throw e;
@@ -76,6 +93,33 @@ public class Hwi {
}
}
private List<Device> enumerateCard() {
List<Device> devices = new ArrayList<>();
if(CardApi.isReaderAvailable()) {
try {
List<WalletModel> connectedCards = CardApi.getConnectedCards();
for(WalletModel card : connectedCards) {
CardApi cardApi = CardApi.getCardApi(card, null);
WalletModel walletModel = cardApi.getCardType();
Device cardDevice = new Device();
cardDevice.setType(walletModel.getType());
cardDevice.setModel(walletModel);
cardDevice.setNeedsPassphraseSent(Boolean.FALSE);
cardDevice.setNeedsPinSent(Boolean.FALSE);
cardDevice.setCard(true);
devices.add(cardDevice);
}
} catch(CardNotPresentException e) {
//ignore
} catch(CardException e) {
log.error("Error reading card", e);
}
}
return devices;
}
public boolean promptPin(Device device) throws ImportException {
try {
String output = execute(getDeviceCommand(device, Command.PROMPT_PIN));
@@ -118,7 +162,7 @@ public class Hwi {
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
try {
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
if(passphrase != null && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.GET_XPUB, derivationPath));
} else {
output = execute(getDeviceCommand(device, Command.GET_XPUB, derivationPath));
@@ -151,7 +195,7 @@ public class Hwi {
isPromptActive = true;
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
if(passphrase != null && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--desc", descriptor));
} else {
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--desc", descriptor));
@@ -179,7 +223,7 @@ public class Hwi {
try {
isPromptActive = true;
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
if(passphrase != null && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceArguments(device, passphrase, Command.SIGN_MESSAGE), Command.SIGN_MESSAGE, message, derivationPath);
} else {
output = execute(getDeviceArguments(device, Command.SIGN_MESSAGE), Command.SIGN_MESSAGE, message, derivationPath);
@@ -209,7 +253,7 @@ public class Hwi {
isPromptActive = true;
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
if(passphrase != null && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceArguments(device, passphrase, Command.SIGN_TX), Command.SIGN_TX, psbtBase64);
} else {
output = execute(getDeviceArguments(device, Command.SIGN_TX), Command.SIGN_TX, psbtBase64);
@@ -142,7 +142,7 @@ public class IOUtils {
pos += data.length;
}
} catch(IOException e) {
log.warn("Error overwriting file for deletion " + file.getName(), e);
log.warn("Error overwriting file for deletion: " + file.getName(), e);
}
return file.delete();
@@ -0,0 +1,64 @@
package com.sparrowwallet.sparrow.io;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import net.coobird.thumbnailator.Thumbnails;
import java.awt.image.BufferedImage;
import java.io.*;
public class ImageUtils {
public static byte[] resize(Image image, int width, int height) {
return resize(SwingFXUtils.fromFXImage(image, null), width, height);
}
public static byte[] resize(BufferedImage image, int width, int height) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
resize(image, baos, width, height);
return baos.toByteArray();
} catch(IOException e) {
throw new RuntimeException(e);
}
}
public static void resize(BufferedImage image, OutputStream outputStream, int width, int height) throws IOException {
resize(Thumbnails.of(image), outputStream, width, height);
}
public static BufferedImage resizeToImage(BufferedImage image, int width, int height) {
try {
return Thumbnails.of(image).size(width, height).outputQuality(1).asBufferedImage();
} catch(IOException e) {
throw new RuntimeException(e);
}
}
public static byte[] resize(File file, int width, int height) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
resize(file, baos, width, height);
return baos.toByteArray();
}
public static void resize(File file, OutputStream outputStream, int width, int height) throws IOException {
resize(Thumbnails.of(file), outputStream, width, height);
}
public static InputStream resize(InputStream inputStream, int width, int height) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
resize(inputStream, baos, width, height);
return new ByteArrayInputStream(baos.toByteArray());
} catch(IOException e) {
throw new RuntimeException(e);
}
}
public static void resize(InputStream inputStream, OutputStream outputStream, int width, int height) throws IOException {
resize(Thumbnails.of(inputStream), outputStream, width, height);
}
private static void resize(Thumbnails.Builder<?> builder, OutputStream outputStream, int width, int height) throws IOException {
builder.size(width, height).outputFormat("png").outputQuality(1).toOutputStream(outputStream);
}
}
@@ -0,0 +1,41 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
public class JadeMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Jade Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.JADE;
}
@Override
public String getWalletExportDescription() {
return "Export a QR that allows Jade to import a multisig wallet using the Scan feature.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return null;
}
@Override
public boolean isWalletExportScannable() {
return true;
}
@Override
public boolean isWalletExportFile() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
}
@@ -0,0 +1,12 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import javafx.beans.property.StringProperty;
import java.util.List;
public interface KeystoreCardImport extends CardImport {
Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException;
String getKeystoreImportDescription(int account);
}
@@ -28,7 +28,7 @@ public class PassportMultisig extends ColdcardMultisig {
@Override
public String getKeystoreImportDescription(int account) {
return "Import file or QR created from New Account > Sparrow > Multisig > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
return "Import file or QR created from Manage Account > Connect Wallet > Sparrow > Multisig > QR Code/microSD on your Passport.";
}
@Override
@@ -38,7 +38,7 @@ public class PassportMultisig extends ColdcardMultisig {
@Override
public String getWalletExportDescription() {
return "As part of the New Account > Sparrow > Multisig flow, Passport will ask you to scan or import the multisig configuration from Sparrow.";
return "As part of the Multisig connection flow, Passport will ask you to scan or import the multisig configuration from Sparrow. To import this configuration manually, go to Settings > Bitcoin > Multisig > Import from QR Code/microSD.";
}
@Override
@@ -14,7 +14,7 @@ public class PassportSinglesig extends ColdcardSinglesig {
@Override
public String getKeystoreImportDescription(int account) {
return "Import file or QR created from New Account > Sparrow > Standard > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
return "Import file or QR created from Manage Account > Connect Wallet > Sparrow > Single-sig > QR Code/microSD on your Passport.";
}
@Override
@@ -31,10 +31,10 @@ public class Sparrow implements WalletImport, WalletExport {
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
Wallet exportedWallet = !wallet.isMasterWallet() ? wallet.getMasterWallet() : wallet;
PersistenceType persistenceType = exportedWallet.getChildWallets().isEmpty() ? PersistenceType.JSON : PersistenceType.DB;
PersistenceType persistenceType = PersistenceType.DB;
Persistence persistence = persistenceType.getInstance();
Storage storage = AppServices.get().getOpenWallets().get(exportedWallet);
File tempFile = File.createTempFile(exportedWallet.getName(), "." + persistenceType.getExtension());
File tempFile = File.createTempFile(exportedWallet.getName() + "tmp", "." + persistenceType.getExtension());
tempFile.delete();
Storage tempStorage = new Storage(persistence, tempFile);
tempStorage.setKeyDeriver(storage.getKeyDeriver());
@@ -60,15 +60,7 @@ public class Sparrow implements WalletImport, WalletExport {
@Override
public String getExportFileExtension(Wallet wallet) {
try {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet exportedWallet = !wallet.isMasterWallet() ? wallet.getMasterWallet() : wallet;
return !exportedWallet.getChildWallets().isEmpty() ? PersistenceType.DB.getExtension() : (storage.isEncrypted() ? "" : PersistenceType.JSON.getExtension());
} catch(IOException e) {
//ignore
}
return "";
return PersistenceType.DB.getExtension();
}
@Override
@@ -8,8 +8,10 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
@@ -144,7 +146,15 @@ public class Storage {
if(wallet.containsMasterPrivateKeys()) {
//Derive xpub and master fingerprint from seed, potentially with passphrase
Wallet copy = wallet.copy();
Wallet copy = wallet.copy(false);
if(wallet.isEncrypted()) {
if(key == null) {
throw new IllegalStateException("Wallet was not encrypted, but seed is");
}
copy.decrypt(key);
}
for(int i = 0; i < copy.getKeystores().size(); i++) {
Keystore copyKeystore = copy.getKeystores().get(i);
if(copyKeystore.hasSeed() && copyKeystore.getSeed().getPassphrase() == null) {
@@ -165,14 +175,6 @@ public class Storage {
}
}
if(wallet.isEncrypted()) {
if(key == null) {
throw new IllegalStateException("Wallet was not encrypted, but seed is");
}
copy.decrypt(key);
}
if(wallet.isWhirlpoolMasterWallet()) {
String walletId = getWalletId(wallet);
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
@@ -263,9 +265,9 @@ public class Storage {
persistence.copyWallet(walletFile, outputStream);
}
public void delete() {
public boolean delete() {
deleteBackups();
IOUtils.secureDelete(walletFile);
return IOUtils.secureDelete(walletFile);
}
public void deleteBackups() {
@@ -732,4 +734,21 @@ public class Storage {
};
}
}
public static class DeleteWalletService extends ScheduledService<Boolean> {
private final Storage storage;
public DeleteWalletService(Storage storage) {
this.storage = storage;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() {
return storage.delete();
}
};
}
}
}
@@ -10,6 +10,9 @@ public interface WalletExport extends ImportExport {
String getExportFileExtension(Wallet wallet);
boolean isWalletExportScannable();
boolean walletExportRequiresDecryption();
default boolean isWalletExportFile() {
return true;
}
default boolean exportsAllWallets() {
return false;
}
@@ -0,0 +1,296 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class WalletLabels implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
private final List<WalletForm> walletForms;
public WalletLabels() {
this.walletForms = Collections.emptyList();
}
public WalletLabels(List<WalletForm> walletForms) {
this.walletForms = walletForms;
}
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public String getName() {
return "Wallet Labels";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.LABELS;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
List<Label> labels = new ArrayList<>();
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets();
for(Wallet exportWallet : allWallets) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
String origin = outputDescriptor.toString(true, false, false);
for(Keystore keystore : exportWallet.getKeystores()) {
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null));
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin));
}
}
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null));
}
}
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null));
}
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null));
}
}
}
try {
Gson gson = new Gson();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
for(Label label : labels) {
writer.write(gson.toJson(label) + "\n");
}
writer.flush();
} catch(Exception e) {
log.error("Error exporting labels", e);
throw new ExportException("Error exporting labels", e);
}
}
@Override
public String getWalletExportDescription() {
return "Exports a file containing labels from this wallet in the BIP329 standard format.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "jsonl";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public String getWalletImportDescription() {
return "Imports a file containing labels in the BIP329 standard format to the currently selected wallet.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
if(walletForms.isEmpty()) {
throw new IllegalStateException("No wallets to import labels for");
}
Gson gson = new Gson();
List<Label> labels = new ArrayList<>();
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String line;
while((line = reader.readLine()) != null) {
Label label;
try {
label = gson.fromJson(line, Label.class);
} catch(Exception e) {
continue;
}
if(label == null || label.type == null || label.ref == null || label.label == null || label.label.isEmpty()) {
continue;
}
labels.add(label);
}
} catch(Exception e) {
throw new ImportException("Error importing labels file", e);
}
Map<Wallet, List<Keystore>> changedWalletKeystores = new LinkedHashMap<>();
Map<Wallet, List<Entry>> changedWalletEntries = new LinkedHashMap<>();
for(WalletForm walletForm : walletForms) {
Wallet wallet = walletForm.getWallet();
if(!wallet.isValid()) {
continue;
}
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet);
String origin = outputDescriptor.toString(true, false, false);
List<Entry> transactionEntries = walletForm.getWalletTransactionsEntry().getChildren();
List<Entry> addressEntries = new ArrayList<>();
addressEntries.addAll(walletForm.getNodeEntry(KeyPurpose.RECEIVE).getChildren());
addressEntries.addAll(walletForm.getNodeEntry(KeyPurpose.CHANGE).getChildren());
List<Entry> utxoEntries = walletForm.getWalletUtxosEntry().getChildren();
for(Label label : labels) {
if(label.origin != null && !label.origin.equals(origin)) {
continue;
}
if(label.type == Type.xpub) {
for(Keystore keystore : wallet.getKeystores()) {
if(keystore.getExtendedPublicKey().toString().equals(label.ref)) {
keystore.setLabel(label.label);
List<Keystore> changedKeystores = changedWalletKeystores.computeIfAbsent(wallet, w -> new ArrayList<>());
changedKeystores.add(keystore);
}
}
}
if(label.type == Type.tx) {
for(Entry entry : transactionEntries) {
if(entry instanceof TransactionEntry transactionEntry) {
BlockTransaction blkTx = transactionEntry.getBlockTransaction();
if(blkTx.getHashAsString().equals(label.ref)) {
transactionEntry.getBlockTransaction().setLabel(label.label);
transactionEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, entry);
}
}
}
}
if(label.type == Type.addr) {
for(Entry addressEntry : addressEntries) {
if(addressEntry instanceof NodeEntry nodeEntry) {
WalletNode addressNode = nodeEntry.getNode();
if(addressNode.getAddress().toString().equals(label.ref)) {
nodeEntry.getNode().setLabel(label.label);
nodeEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, addressEntry);
}
}
}
}
if(label.type == Type.output || label.type == Type.input) {
for(Entry entry : transactionEntries) {
for(Entry hashIndexEntry : entry.getChildren()) {
if(hashIndexEntry instanceof TransactionHashIndexEntry txioEntry) {
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
txioEntry.getHashIndex().setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
}
}
}
}
for(Entry addressEntry : addressEntries) {
for(Entry entry : addressEntry.getChildren()) {
updateHashIndexEntryLabel(label, entry);
for(Entry spentEntry : entry.getChildren()) {
updateHashIndexEntryLabel(label, spentEntry);
}
}
}
for(Entry entry : utxoEntries) {
updateHashIndexEntryLabel(label, entry);
}
}
}
}
for(Map.Entry<Wallet, List<Keystore>> walletKeystores : changedWalletKeystores.entrySet()) {
Wallet wallet = walletKeystores.getKey();
Storage storage = AppServices.get().getOpenWallets().get(wallet);
EventManager.get().post(new KeystoreLabelsChangedEvent(wallet, wallet, storage.getWalletId(wallet), walletKeystores.getValue()));
}
for(Map.Entry<Wallet, List<Entry>> walletEntries : changedWalletEntries.entrySet()) {
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletEntries.getKey(), walletEntries.getValue(), false));
}
return walletForms.get(0).getWallet();
}
private static void updateHashIndexEntryLabel(Label label, Entry entry) {
if(entry instanceof HashIndexEntry hashIndexEntry) {
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
hashIndexEntry.labelProperty().set(label.label);
}
}
}
private static void addChangedEntry(Map<Wallet, List<Entry>> changedEntries, Entry entry) {
List<Entry> entries = changedEntries.computeIfAbsent(entry.getWallet(), wallet -> new ArrayList<>());
entries.add(entry);
}
@Override
public boolean isWalletImportScannable() {
return false;
}
@Override
public boolean exportsAllWallets() {
return true;
}
private enum Type {
tx, addr, pubkey, input, output, xpub
}
private static class Label {
public Label(Type type, String ref, String label, String origin) {
this.type = type;
this.ref = ref;
this.label = label;
this.origin = origin;
}
Type type;
String ref;
String label;
String origin;
}
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardAuthDump extends CardUnseal {
boolean tampered;
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardBackup extends CardResponse {
byte[] data;
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.io.ckcard;
import java.util.List;
public class CardCerts {
List<byte[]> cert_chain;
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardChange extends CardResponse {
boolean success;
}
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import java.math.BigInteger;
import java.util.Arrays;
public class CardDerive extends CardResponse {
byte[] sig;
byte[] chain_code;
byte[] master_pubkey;
byte[] pubkey;
public ECDSASignature getSignature() {
BigInteger r = new BigInteger(1, Arrays.copyOfRange(sig, 0, 32));
BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64));
return new ECDSASignature(r, s);
}
}
@@ -0,0 +1,26 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import javax.smartcardio.CardException;
public class CardDump extends CardResponse {
int slot;
boolean used = true;
boolean sealed;
String address;
byte[] pubkey;
public Address getAddress() throws CardException {
try {
if(address != null) {
return Address.fromString(address);
}
} catch(InvalidAddressException e) {
throw new CardException("Invalid address provided", e);
}
return null;
}
}
@@ -0,0 +1,321 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.google.gson.*;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.sparrow.io.CardSignFailedException;
import com.sparrowwallet.sparrow.io.CardUnluckyNumberException;
import org.bitcoin.NativeSecp256k1;
import org.bitcoin.NativeSecp256k1Util;
import org.bitcoin.Secp256k1Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.SignatureException;
import java.util.*;
import java.util.stream.Collectors;
public class CardProtocol {
private static final Logger log = LoggerFactory.getLogger(CardProtocol.class);
public static final byte[] OPENDIME_HEADER = "OPENDIME".getBytes(StandardCharsets.UTF_8);
public static final List<byte[]> FACTORY_ROOT_KEYS = List.of(Utils.hexToBytes("03028a0e89e70d0ec0d932053a89ab1da7d9182bdc6d2f03e706ee99517d05d9e1"));
public static final int MAX_PATH_DEPTH = 8;
private final CardTransport cardTransport;
private final Gson gson;
private final SecureRandom secureRandom = new SecureRandom();
private byte[] cardPubkey;
private byte[] lastCardNonce;
public CardProtocol() throws CardException {
this.cardTransport = new CardTransport();
this.gson = new GsonBuilder().registerTypeAdapter(byte[].class, new ByteArrayToHexTypeAdapter()).create();
}
public CardStatus getStatus() throws CardException {
JsonObject status = send("status");
CardStatus cardStatus = gson.fromJson(status, CardStatus.class);
if(cardPubkey == null) {
cardPubkey = cardStatus.pubkey;
}
return cardStatus;
}
public CardCerts getCerts() throws CardException {
JsonObject certs = send("certs");
return gson.fromJson(certs, CardCerts.class);
}
public void verify() throws CardException {
CardCerts cardCerts = getCerts();
byte[] userNonce = getNonce();
byte[] cardNonce = lastCardNonce;
JsonObject certs = send("check", Map.of("nonce", userNonce));
CardSignature cardSignature = gson.fromJson(certs, CardSignature.class);
Sha256Hash verificationData = getVerificationData(cardNonce, userNonce, new byte[0]);
ECDSASignature ecdsaSignature = cardSignature.getSignature();
if(!ecdsaSignature.verify(verificationData.getBytes(), cardPubkey)) {
throw new CardException("Card authentication failure: Provided signature did not match public key");
}
byte[] pubkey = cardPubkey;
for(byte[] cert : cardCerts.cert_chain) {
Sha256Hash pubkeyHash = Sha256Hash.of(pubkey);
try {
ECKey recoveredKey = ECKey.signedHashToKey(pubkeyHash, cert, false);
pubkey = recoveredKey.getPubKey();
} catch(SignatureException e) {
throw new CardException("Card signature error", e);
}
}
byte[] rootPubKey = pubkey;
if(FACTORY_ROOT_KEYS.stream().noneMatch(key -> Arrays.equals(key, rootPubKey))) {
throw new CardException("Card authentication failure: Could not verify to root certificate");
}
}
public CardRead read(String cvc, int currentSlot) throws CardException {
byte[] userNonce = getNonce();
byte[] cardNonce = lastCardNonce;
Map<String, Object> args = new HashMap<>();
args.put("nonce", userNonce);
JsonObject read;
if(cvc == null) {
read = send("read", args);
} else {
read = sendAuth("read", args, cvc);
}
CardRead cardRead = gson.fromJson(read, CardRead.class);
ECDSASignature ecdsaSignature = cardRead.getSignature();
Sha256Hash verificationData = getVerificationData(cardNonce, userNonce, new byte[] { (byte)currentSlot });
if(!ecdsaSignature.verify(verificationData.getBytes(), cardRead.pubkey)) {
throw new CardException("Card authentication failure: Provided signature did not match public key");
}
return cardRead;
}
public CardSetup setup(String cvc, int slot, byte[] chainCode) throws CardException {
if(chainCode == null) {
chainCode = Sha256Hash.hashTwice(secureRandom.generateSeed(128));
}
if(chainCode.length != 32) {
throw new IllegalArgumentException("Invalid chain code length of " + chainCode.length);
}
Map<String, Object> args = new HashMap<>();
args.put("slot", slot);
args.put("chain_code", chainCode);
JsonObject setup = sendAuth("new", args, cvc);
return gson.fromJson(setup, CardSetup.class);
}
public CardWait authWait() throws CardException {
JsonObject wait = send("wait");
return gson.fromJson(wait, CardWait.class);
}
public CardXpub xpub(String cvc, boolean master) throws CardException {
Map<String, Object> args = new HashMap<>();
args.put("master", master);
JsonObject xpub = sendAuth("xpub", args, cvc);
return gson.fromJson(xpub, CardXpub.class);
}
public CardDerive derive(String cvc, List<ChildNumber> path) throws CardException {
if(path.stream().anyMatch(childNumber -> !childNumber.isHardened())) {
throw new IllegalArgumentException("Derivation path cannot contain unhardened components");
}
if(path.size() > MAX_PATH_DEPTH) {
throw new IllegalArgumentException("Derivation path cannot have more than " + MAX_PATH_DEPTH + " components");
}
if(lastCardNonce == null || cardPubkey == null) {
getStatus();
}
byte[] userNonce = getNonce();
Map<String, Object> args = new HashMap<>();
args.put("path", path.stream().map(cn -> Integer.toUnsignedLong(cn.i())).collect(Collectors.toList()));
args.put("nonce", userNonce);
JsonObject derive = sendAuth("derive", args, cvc);
return gson.fromJson(derive, CardDerive.class);
}
public CardSign sign(String cvc, List<ChildNumber> subpath, Sha256Hash digest) throws CardException {
if(subpath.stream().anyMatch(ChildNumber::isHardened)) {
throw new IllegalArgumentException("Derivation path cannot contain hardened components");
}
Map<String, Object> args = new HashMap<>();
args.put("subpath", subpath.stream().map(cn -> Integer.toUnsignedLong(cn.i())).collect(Collectors.toList()));
args.put("digest", digest.getBytes());
for(int attempt = 0; attempt < 5; attempt++) {
try {
JsonObject sign = sendAuth("sign", args, cvc);
CardSign cardSign = gson.fromJson(sign, CardSign.class);
if(!cardSign.getSignature().verify(digest.getBytes(), cardSign.pubkey)) {
continue;
}
return cardSign;
} catch(CardUnluckyNumberException e) {
log.debug("Got unlucky number signing, trying again...");
}
}
throw new CardSignFailedException("Failed to sign digest after 5 tries. It's safe to try again.");
}
public CardChange change(String currentCvc, String newCvc) throws CardException {
if(newCvc.length() < 6 || newCvc.length() > 32) {
throw new IllegalArgumentException("CVC cannot be of length " + newCvc.length());
}
Map<String, Object> args = new HashMap<>();
args.put("data", newCvc.getBytes(StandardCharsets.UTF_8));
JsonObject change = sendAuth("change", args, currentCvc);
return gson.fromJson(change, CardChange.class);
}
public CardBackup backup(String cvc) throws CardException {
JsonObject backup = sendAuth("backup", new HashMap<>(), cvc);
return gson.fromJson(backup, CardBackup.class);
}
public CardUnseal unseal(String cvc, int slot) throws CardException {
Map<String, Object> args = new HashMap<>();
args.put("slot", slot);
JsonObject unseal = sendAuth("unseal", args, cvc);
return gson.fromJson(unseal, CardUnseal.class);
}
public CardDump dump(int slot) throws CardException {
Map<String, Object> args = new HashMap<>();
args.put("slot", slot);
JsonObject dump = send("dump", args);
return gson.fromJson(dump, CardDump.class);
}
public CardAuthDump dump(String cvc, int slot) throws CardException {
Map<String, Object> args = new HashMap<>();
args.put("slot", slot);
JsonObject dump = sendAuth("dump", args, cvc);
return gson.fromJson(dump, CardAuthDump.class);
}
public void disconnect() throws CardException {
cardTransport.disconnect();
}
private JsonObject send(String cmd) throws CardException {
return send(cmd, Collections.emptyMap());
}
private JsonObject send(String cmd, Map<String, Object> args) throws CardException {
JsonObject jsonObject = cardTransport.send(cmd, args);
if(jsonObject.get("card_nonce") != null) {
lastCardNonce = gson.fromJson(jsonObject, CardResponse.class).card_nonce;
}
return jsonObject;
}
private JsonObject sendAuth(String cmd, Map<String, Object> args, String cvc) throws CardException {
byte[] sessionKey = addAuth(cmd, args, cvc);
JsonObject jsonObject = send(cmd, args);
if(jsonObject.get("privkey") != null) {
byte[] privKeyBytes = Utils.hexToBytes(jsonObject.get("privkey").getAsString());
jsonObject.add("privkey", new JsonPrimitive(Utils.bytesToHex(Utils.xor(sessionKey, privKeyBytes))));
}
return jsonObject;
}
public byte[] addAuth(String cmd, Map<String, Object> args, String cvc) throws CardException {
if(cvc.length() < 6 || cvc.length() > 32) {
throw new IllegalArgumentException("CVC cannot be of length " + cvc.length());
}
if(lastCardNonce == null || cardPubkey == null) {
getStatus();
}
try {
ECKey ephemeralKey = new ECKey(secureRandom);
if(Secp256k1Context.isEnabled()) {
byte[] sessionKey = NativeSecp256k1.createECDHSecret(ephemeralKey.getPrivKeyBytes(), cardPubkey);
byte[] md = Sha256Hash.hash(Utils.concat(lastCardNonce, cmd.getBytes(StandardCharsets.UTF_8)));
byte[] mask = Arrays.copyOf(Utils.xor(sessionKey, md), cvc.length());
byte[] xcvc = Utils.xor(cvc.getBytes(StandardCharsets.UTF_8), mask);
args.put("epubkey", ephemeralKey.getPubKey());
args.put("xcvc", xcvc);
if(cmd.equals("sign") && args.get("digest") instanceof byte[] digestBytes) {
args.put("digest", Utils.xor(digestBytes, sessionKey));
} else if(cmd.equals("change") && args.get("data") instanceof byte[] dataBytes) {
args.put("data", Utils.xor(dataBytes, Arrays.copyOf(sessionKey, dataBytes.length)));
}
return sessionKey;
} else {
throw new IllegalStateException("Native library libsecp256k1 required but not enabled");
}
} catch(NativeSecp256k1Util.AssertFailException e) {
throw new RuntimeException(e);
}
}
private Sha256Hash getVerificationData(byte[] cardNonce, byte[] userNonce, byte[] commandData) {
byte[] data = new byte[40 + commandData.length];
System.arraycopy(OPENDIME_HEADER, 0, data, 0, 8);
System.arraycopy(cardNonce, 0, data, 8, 16);
System.arraycopy(userNonce, 0, data, 24, 16);
System.arraycopy(commandData, 0, data, 40, commandData.length);
return Sha256Hash.of(data);
}
private byte[] getNonce() {
byte[] nonce = new byte[16];
secureRandom.nextBytes(nonce);
return nonce;
}
private static class ByteArrayToHexTypeAdapter implements JsonSerializer<byte[]>, JsonDeserializer<byte[]> {
public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Utils.hexToBytes(json.getAsString());
}
public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(Utils.bytesToHex(src));
}
}
}
@@ -0,0 +1,26 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import com.sparrowwallet.drongo.crypto.ECKey;
import java.math.BigInteger;
import java.util.Arrays;
public class CardRead extends CardResponse {
byte[] sig;
byte[] pubkey;
public ECDSASignature getSignature() {
if(sig != null) {
BigInteger r = new BigInteger(1, Arrays.copyOfRange(sig, 0, 32));
BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64));
return new ECDSASignature(r, s);
}
return null;
}
public ECKey getPubKey() {
return ECKey.fromPublicOnly(pubkey);
}
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardResponse {
byte[] card_nonce;
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardSetup extends CardResponse {
int slot;
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardSign extends CardRead {
int slot;
}
@@ -0,0 +1,16 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.crypto.ECDSASignature;
import java.math.BigInteger;
import java.util.Arrays;
public class CardSignature extends CardResponse {
byte[] auth_sig;
public ECDSASignature getSignature() {
BigInteger r = new BigInteger(1, Arrays.copyOfRange(auth_sig, 0, 32));
BigInteger s = new BigInteger(1, Arrays.copyOfRange(auth_sig, 32, 64));
return new ECDSASignature(r, s);
}
}
@@ -0,0 +1,84 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.google.common.io.BaseEncoding;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class CardStatus extends CardResponse {
int proto;
String ver;
BigInteger birth;
boolean tapsigner;
List<BigInteger> path;
BigInteger num_backups;
List<BigInteger> slots;
String addr;
byte[] pubkey;
BigInteger auth_delay;
boolean testnet;
public boolean isInitialized() {
return (getCardType() == WalletModel.TAPSIGNER && path != null) || (getCardType() == WalletModel.SATSCARD && addr != null);
}
public String getIdentifier() {
byte[] pubkeyHash = Sha256Hash.hash(pubkey);
String base32 = BaseEncoding.base32().encode(Arrays.copyOfRange(pubkeyHash, 8, 32));
return base32.replaceAll("(.{5})", "$1-");
}
public List<ChildNumber> getDerivation() {
if(isInitialized()) {
return path.stream().map(i -> new ChildNumber(i.intValue())).collect(Collectors.toList());
}
return null;
}
public boolean requiresBackup() {
return num_backups == null || num_backups.intValue() == 0;
}
public WalletModel getCardType() {
return tapsigner ? WalletModel.TAPSIGNER : WalletModel.SATSCARD;
}
public int getCurrentSlot() {
if(slots == null || slots.isEmpty()) {
return 0;
}
return slots.get(0).intValue();
}
public int getLastSlot() {
if(slots == null || slots.size() < 2) {
return 0;
}
return slots.get(1).intValue();
}
@Override
public String toString() {
return "CardStatus{" +
"proto=" + proto +
", ver='" + ver + '\'' +
", birth=" + birth +
", tapsigner=" + tapsigner +
", path=" + path +
", num_backups=" + num_backups +
", slots=" + slots +
", addr='" + addr + '\'' +
", pubkey=" + Arrays.toString(pubkey) +
", auth_delay=" + auth_delay +
", testnet=" + testnet +
'}';
}
}
@@ -0,0 +1,167 @@
package com.sparrowwallet.sparrow.io.ckcard;
import co.nstant.in.cbor.CborBuilder;
import co.nstant.in.cbor.CborDecoder;
import co.nstant.in.cbor.CborEncoder;
import co.nstant.in.cbor.CborException;
import co.nstant.in.cbor.builder.ArrayBuilder;
import co.nstant.in.cbor.builder.MapBuilder;
import co.nstant.in.cbor.model.*;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
import com.sparrowwallet.sparrow.io.CardUnluckyNumberException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class CardTransport {
private static final Logger log = LoggerFactory.getLogger(CardTransport.class);
public static final String APPID = "f0436f696e6b697465434152447631";
private static final int CBOR_CLA = 0x00;
private static final int CBOR_INS = 0xCB;
private static final int SW_OKAY = 0x9000;
private final Card connection;
CardTransport() throws CardException {
TerminalFactory tf = TerminalFactory.getDefault();
List<CardTerminal> terminals = tf.terminals().list();
if(terminals.isEmpty()) {
throw new IllegalStateException("No reader connected");
}
CardTerminal cardTerminal = (CardTerminal)terminals.get(0);
connection = cardTerminal.connect("*");
CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
if(resp.getSW() != SW_OKAY) {
log.error("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported.");
}
}
JsonObject send(String cmd, Map<String, Object> args) throws CardException {
Map<String, Object> sendMap = new LinkedHashMap<>();
sendMap.put("cmd", cmd);
sendMap.putAll(args);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MapBuilder<CborBuilder> cborBuilder = new CborBuilder().addMap();
for(Map.Entry<String, Object> entry : sendMap.entrySet()) {
if(entry.getValue() instanceof String strValue) {
cborBuilder.put(entry.getKey(), strValue);
} else if(entry.getValue() instanceof byte[] byteValue) {
cborBuilder.put(entry.getKey(), byteValue);
} else if(entry.getValue() instanceof Long longValue) {
cborBuilder.put(entry.getKey(), longValue);
} else if(entry.getValue() instanceof Integer integerValue) {
cborBuilder.put(entry.getKey(), integerValue);
} else if(entry.getValue() instanceof Boolean booleanValue) {
cborBuilder.put(entry.getKey(), booleanValue);
} else if(entry.getValue() instanceof List<?> listValue) {
ArrayBuilder<MapBuilder<CborBuilder>> arrayBuilder = cborBuilder.putArray(entry.getKey());
for(Object value : listValue) {
if(value instanceof String strValue) {
arrayBuilder.add(strValue);
} else if(value instanceof byte[] byteValue) {
arrayBuilder.add(byteValue);
} else if(value instanceof Long longValue) {
arrayBuilder.add(longValue);
} else if(value instanceof Boolean booleanValue) {
arrayBuilder.add(booleanValue);
}
}
arrayBuilder.end();
}
}
try {
new CborEncoder(baos).encode(cborBuilder.end().build());
byte[] sendBytes = baos.toByteArray();
CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(CBOR_CLA, CBOR_INS, 0, 0, sendBytes));
if(resp.getSW() != SW_OKAY) {
throw new CardException("Received error SW value " + resp.getSW());
}
ByteArrayInputStream bais = new ByteArrayInputStream(resp.getData());
List<DataItem> dataItems = new CborDecoder(bais).decode();
for(DataItem dataItem : dataItems) {
if(dataItem instanceof co.nstant.in.cbor.model.Map map) {
JsonObject result = new JsonObject();
for(DataItem key : map.getKeys()) {
String strKey = key.toString();
result.add(strKey, getJsonElement(map.get(key)));
}
if(result.get("error") != null) {
String msg = result.get("error").getAsString();
int code = result.get("code") == null ? 500 : result.get("code").getAsInt();
if(code == 205) {
throw new CardUnluckyNumberException("Card chose unlucky number, please retry");
} else if(code == 401) {
throw new CardAuthorizationException("Incorrect PIN provided");
}
throw new CardException(code + " on " + cmd + ": " + msg);
}
return result;
}
}
} catch(CborException e) {
log.error("CBOR encoding error", e);
}
return new JsonObject();
}
private JsonElement getJsonElement(DataItem dataItem) {
if(dataItem instanceof UnicodeString strValue) {
return new JsonPrimitive(strValue.toString());
} else if(dataItem instanceof ByteString byteString) {
return new JsonPrimitive(Utils.bytesToHex(byteString.getBytes()));
} else if(dataItem instanceof UnsignedInteger unsignedInteger) {
return new JsonPrimitive(unsignedInteger.getValue());
} else if(dataItem instanceof SimpleValue simpleValue) {
return new JsonPrimitive(simpleValue.getValue() == SimpleValueType.TRUE.getValue());
} else if(dataItem instanceof Array array) {
JsonArray jsonArray = new JsonArray();
for(DataItem item : array.getDataItems()) {
jsonArray.add(getJsonElement(item));
}
return jsonArray;
}
throw new IllegalArgumentException("Cannot convert dataItem of type " + dataItem.getClass() + "to JsonElement");
}
void disconnect() throws CardException {
connection.disconnect(true);
}
static boolean isReaderAvailable() {
try {
TerminalFactory tf = TerminalFactory.getDefault();
return !tf.terminals().list().isEmpty();
} catch(Exception e) {
log.error("Error detecting card terminals", e);
}
return false;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.crypto.ECKey;
public class CardUnseal extends CardResponse {
int slot;
byte[] privkey;
byte[] pubkey;
byte[] master_pk;
byte[] chain_code;
public ECKey getPrivateKey() {
return ECKey.fromPrivate(privkey);
}
}
@@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.io.ckcard;
import java.math.BigInteger;
public class CardWait {
boolean success;
BigInteger auth_delay;
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io.ckcard;
public class CardXpub {
byte[] xpub;
}
@@ -0,0 +1,531 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.CardImportPane;
import com.sparrowwallet.sparrow.io.CardApi;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class CkCardApi extends CardApi {
private static final Logger log = LoggerFactory.getLogger(CkCardApi.class);
private final WalletModel cardType;
private final CardProtocol cardProtocol;
private String cvc;
public CkCardApi(String cvc) throws CardException {
this(WalletModel.TAPSIGNER, cvc);
}
public CkCardApi(WalletModel cardType, String cvc) throws CardException {
this.cardType = cardType;
this.cardProtocol = new CardProtocol();
this.cvc = cvc;
}
@Override
public boolean isInitialized() throws CardException {
CardStatus cardStatus = getStatus();
return cardStatus.isInitialized();
}
@Override
public void initialize(int slot, byte[] chainCode) throws CardException {
cardProtocol.verify();
cardProtocol.setup(cvc, slot, chainCode);
}
@Override
public WalletModel getCardType() throws CardException {
CardStatus cardStatus = getStatus();
return cardStatus.getCardType();
}
@Override
public int getCurrentSlot() throws CardException {
CardStatus cardStatus = getStatus();
return cardStatus.getCurrentSlot();
}
@Override
public ScriptType getDefaultScriptType() {
return ScriptType.P2WPKH;
}
CardStatus getStatus() throws CardException {
CardStatus cardStatus = cardProtocol.getStatus();
if(cardType != null && cardStatus.getCardType() != cardType) {
throw new CardException("Please use a " + cardType.toDisplayString() + " card.");
}
return cardStatus;
}
void checkWait(CardStatus cardStatus, IntegerProperty delayProperty, StringProperty messageProperty) throws CardException {
if(cardStatus.auth_delay != null) {
int delay = cardStatus.auth_delay.intValue();
while(delay > 0) {
delayProperty.set(delay);
messageProperty.set("Auth delay, waiting " + delay + "s...");
CardWait cardWait = cardProtocol.authWait();
if(cardWait.success) {
delay = cardWait.auth_delay == null ? 0 : cardWait.auth_delay.intValue();
}
}
}
}
@Override
public Service<Void> getAuthDelayService() throws CardException {
CardStatus cardStatus = getStatus();
if(cardStatus.auth_delay != null) {
return new AuthDelayService(cardStatus);
}
return null;
}
@Override
public boolean requiresBackup() throws CardException {
CardStatus cardStatus = getStatus();
return cardStatus.requiresBackup();
}
@Override
public Service<String> getBackupService() {
return new BackupService();
}
String getBackup() throws CardException {
CardBackup cardBackup = cardProtocol.backup(cvc);
return Base64.getEncoder().encodeToString(cardBackup.data);
}
@Override
public boolean changePin(String newCvc) throws CardException {
CardChange cardChange = cardProtocol.change(cvc, newCvc);
if(cardChange.success) {
cvc = newCvc;
}
return cardChange.success;
}
void setDerivation(List<ChildNumber> derivation) throws CardException {
cardProtocol.derive(cvc, derivation);
}
@Override
public Service<Void> getInitializationService(byte[] entropy, StringProperty messageProperty) {
if(cardType == WalletModel.TAPSIGNER) {
return new CardImportPane.CardInitializationService(new Tapsigner(), cvc, entropy, messageProperty);
}
return new CardInitializationService(entropy, messageProperty);
}
@Override
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
return new CardImportPane.CardImportService(new Tapsigner(), cvc, derivation, messageProperty);
}
@Override
public Keystore getKeystore() throws CardException {
KeyDerivation keyDerivation = getKeyDerivation();
CardXpub derivedXpub = cardProtocol.xpub(cvc, false);
ExtendedKey derivedXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(derivedXpub.xpub));
Keystore keystore = new Keystore();
keystore.setLabel(WalletModel.TAPSIGNER.toDisplayString());
keystore.setKeyDerivation(keyDerivation);
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setExtendedPublicKey(derivedXpubkey);
keystore.setWalletModel(WalletModel.TAPSIGNER);
return keystore;
}
private KeyDerivation getKeyDerivation() throws CardException {
String masterFingerprint = getMasterFingerprint();
return new KeyDerivation(masterFingerprint, getStatus().getDerivation());
}
private String getMasterFingerprint() throws CardException {
CardXpub masterXpub = cardProtocol.xpub(cvc, true);
ExtendedKey masterXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(masterXpub.xpub));
return Utils.bytesToHex(masterXpubkey.getKey().getFingerprint());
}
@Override
public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
return new SignService(wallet, psbt, messageProperty);
}
void sign(Wallet wallet, PSBT psbt) throws CardException {
Keystore cardKeystore = getKeystore();
KeyDerivation cardKeyDerivation = cardKeystore.getKeyDerivation();
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(!psbtInput.isSigned()) {
WalletNode signingNode = signingNodes.get(psbtInput);
KeyDerivation changedDerivation = null;
try {
ECKey cardSigningPubKey = cardKeystore.getPubKey(signingNode);
if(wallet.getKeystores().stream().noneMatch(keystore -> keystore.getPubKey(signingNode).equals(cardSigningPubKey))) {
Optional<KeyDerivation> optKeyDerivation = wallet.getKeystores().stream().map(Keystore::getKeyDerivation)
.filter(kd -> kd.getMasterFingerprint().equals(cardKeyDerivation.getMasterFingerprint()) && !kd.getDerivation().equals(cardKeyDerivation.getDerivation())).findFirst();
if(optKeyDerivation.isPresent()) {
changedDerivation = optKeyDerivation.get();
setDerivation(changedDerivation.getDerivation());
Keystore changedKeystore = getKeystore();
ECKey changedSigningPubKey = changedKeystore.getPubKey(signingNode);
if(wallet.getKeystores().stream().noneMatch(keystore -> keystore.getPubKey(signingNode).equals(changedSigningPubKey))) {
throw new CardException("Card cannot recognise public key for signing address.");
}
} else {
throw new CardException("Card cannot recognise public key for signing address.");
}
}
psbtInput.sign(new CardPSBTInputSigner(signingNode));
} finally {
if(changedDerivation != null) {
setDerivation(cardKeyDerivation.getDerivation());
}
}
}
}
}
@Override
public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
return new SignMessageService(message, scriptType, derivation, messageProperty);
}
String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
List<ChildNumber> keystoreDerivation = derivation.subList(0, derivation.size() - 2);
List<ChildNumber> subPathDerivation = derivation.subList(derivation.size() - 2, derivation.size());
Keystore cardKeystore = getKeystore();
KeyDerivation cardKeyDerivation = cardKeystore.getKeyDerivation();
Keystore signingKeystore = cardKeystore;
try {
if(!cardKeyDerivation.getDerivation().equals(keystoreDerivation)) {
setDerivation(keystoreDerivation);
signingKeystore = getKeystore();
}
WalletNode addressNode = new WalletNode(KeyDerivation.writePath(subPathDerivation));
ECKey addressPubKey = signingKeystore.getPubKey(addressNode);
return addressPubKey.signMessage(message, scriptType, hash -> {
try {
CardSign cardSign = cardProtocol.sign(cvc, subPathDerivation, hash);
return cardSign.getSignature();
} catch(CardException e) {
throw new RuntimeException(e);
}
});
} finally {
if(signingKeystore != cardKeystore) {
setDerivation(cardKeystore.getKeyDerivation().getDerivation());
}
}
}
@Override
public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
return new PrivateKeyService(slot, messageProperty);
}
ECKey getPrivateKey(int slot, int currentSlot) throws CardException {
if(slot != currentSlot) {
CardAuthDump cardAuthDump = cardProtocol.dump(cvc, slot);
return cardAuthDump.getPrivateKey();
}
CardUnseal cardUnseal = cardProtocol.unseal(cvc, slot);
return cardUnseal.getPrivateKey();
}
@Override
public Service<Address> getAddressService(StringProperty messageProperty) {
return new AddressService(messageProperty);
}
Address getAddress(int currentSlot, int lastSlot, String addr) throws CardException {
if(currentSlot == lastSlot) {
CardDump cardDump = cardProtocol.dump(currentSlot);
if(!cardDump.sealed) {
return cardDump.getAddress();
}
}
CardRead cardRead = cardProtocol.read(null, currentSlot);
Address address = getDefaultScriptType().getAddress(cardRead.getPubKey());
String left = addr.substring(0, addr.indexOf('_'));
String right = addr.substring(addr.lastIndexOf('_') + 1);
if(!address.toString().startsWith(left) || !address.toString().endsWith(right)) {
throw new CardException("Card authentication failed: Provided pubkey does not match given address");
}
return address;
}
@Override
public void disconnect() {
try {
cardProtocol.disconnect();
} catch(CardException e) {
log.warn("Error disconnecting from card reader", e);
}
}
public class CardInitializationService extends Service<Void> {
private final byte[] chainCode;
private final StringProperty messageProperty;
public CardInitializationService(byte[] chainCode, StringProperty messageProperty) {
this.chainCode = chainCode;
this.messageProperty = messageProperty;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.SATSCARD) {
throw new IllegalStateException("Please use a " + WalletModel.SATSCARD.toDisplayString() + ".");
}
if(cardStatus.isInitialized()) {
throw new IllegalStateException("Card already initialized.");
}
checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
initialize(cardStatus.getCurrentSlot(), chainCode);
return null;
}
};
}
}
public class AuthDelayService extends Service<Void> {
private final CardStatus cardStatus;
private final IntegerProperty delayProperty;
private final StringProperty messageProperty;
AuthDelayService(CardStatus cardStatus) {
this.cardStatus = cardStatus;
this.delayProperty = new SimpleIntegerProperty();
this.messageProperty = new SimpleStringProperty();
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
delayProperty.addListener((observable, oldValue, newValue) -> updateProgress(cardStatus.auth_delay.intValue() - newValue.intValue(), cardStatus.auth_delay.intValue()));
messageProperty.addListener((observable, oldValue, newValue) -> updateMessage(newValue));
checkWait(cardStatus, delayProperty, messageProperty);
return null;
}
};
}
}
public class BackupService extends Service<String> {
@Override
protected Task<String> createTask() {
return new Task<>() {
@Override
protected String call() throws Exception {
return getBackup();
}
};
}
}
public class SignService extends Service<PSBT> {
private final Wallet wallet;
private final PSBT psbt;
private final StringProperty messageProperty;
public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
this.wallet = wallet;
this.psbt = psbt;
this.messageProperty = messageProperty;
}
@Override
protected Task<PSBT> createTask() {
return new Task<>() {
@Override
protected PSBT call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.TAPSIGNER) {
throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign transactions.");
}
checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
sign(wallet, psbt);
return psbt;
}
};
}
}
private class CardPSBTInputSigner implements PSBTInputSigner {
private final WalletNode signingNode;
private ECKey pubkey;
public CardPSBTInputSigner(WalletNode signingNode) {
this.signingNode = signingNode;
}
@Override
public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
if(signatureType != TransactionSignature.Type.ECDSA) {
throw new IllegalStateException(WalletModel.TAPSIGNER.toDisplayString() + " cannot sign " + signatureType + " transactions.");
}
try {
CardSign cardSign = cardProtocol.sign(cvc, signingNode.getDerivation(), hash);
pubkey = cardSign.getPubKey();
return new TransactionSignature(cardSign.getSignature(), sigHash);
} catch(CardException e) {
throw new RuntimeException(e);
}
}
@Override
public ECKey getPubKey() {
return pubkey;
}
}
public class SignMessageService extends Service<String> {
private final String message;
private final ScriptType scriptType;
private final List<ChildNumber> derivation;
private final StringProperty messageProperty;
public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
this.message = message;
this.scriptType = scriptType;
this.derivation = derivation;
this.messageProperty = messageProperty;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
@Override
protected String call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.TAPSIGNER) {
throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign messages.");
}
checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
return signMessage(message, scriptType, derivation);
}
};
}
}
public class PrivateKeyService extends Service<ECKey> {
private Integer slot;
private final StringProperty messageProperty;
public PrivateKeyService(Integer slot, StringProperty messageProperty) {
this.slot = slot;
this.messageProperty = messageProperty;
}
@Override
protected Task<ECKey> createTask() {
return new Task<>() {
@Override
protected ECKey call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.SATSCARD) {
throw new IllegalStateException("Please use a " + WalletModel.SATSCARD.toDisplayString() + " to retrieve a private key.");
}
int currentSlot = cardStatus.getCurrentSlot();
if(slot == null) {
slot = currentSlot;
}
if(slot == currentSlot && !cardStatus.isInitialized()) {
//If card has been unsealed, but a new slot is not initialized, retrieve private key for previous slot
slot = slot - 1;
}
checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
return getPrivateKey(slot, currentSlot);
}
};
}
}
public class AddressService extends Service<Address> {
private final StringProperty messageProperty;
public AddressService(StringProperty messageProperty) {
this.messageProperty = messageProperty;
}
@Override
protected Task<Address> createTask() {
return new Task<>() {
@Override
protected Address call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.SATSCARD) {
throw new IllegalStateException("Please use a " + WalletModel.SATSCARD.toDisplayString() + " to retrieve an address.");
}
if(!cardStatus.isInitialized()) {
throw new IllegalStateException("Please re-initialize card before attempting to get the address.");
}
checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
return getAddress(cardStatus.getCurrentSlot(), cardStatus.getLastSlot(), cardStatus.addr);
}
};
}
}
}
@@ -0,0 +1,100 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import com.sparrowwallet.sparrow.io.ImportException;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.StringProperty;
import javax.smartcardio.CardException;
import java.util.List;
public class Tapsigner implements KeystoreCardImport {
@Override
public boolean isInitialized() throws CardException {
CkCardApi cardApi = null;
try {
cardApi = new CkCardApi(null);
return cardApi.isInitialized();
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException {
if(pin.length() < 6) {
throw new CardException("PIN too short.");
}
if(pin.length() > 32) {
throw new CardException("PIN too long.");
}
CkCardApi cardApi = null;
try {
cardApi = new CkCardApi(pin);
CardStatus cardStatus = cardApi.getStatus();
if(cardStatus.isInitialized()) {
throw new IllegalStateException("Card is already initialized.");
}
cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
cardApi.initialize(0, chainCode);
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
if(pin.length() < 6) {
throw new ImportException("PIN too short.");
}
if(pin.length() > 32) {
throw new ImportException("PIN too long.");
}
CkCardApi cardApi = null;
try {
cardApi = new CkCardApi(pin);
CardStatus cardStatus = cardApi.getStatus();
if(!cardStatus.isInitialized()) {
throw new IllegalStateException("Card is not initialized.");
}
cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
if(!derivation.equals(cardStatus.getDerivation())) {
cardApi.setDerivation(derivation);
}
return cardApi.getKeystore();
} catch(Exception e) {
throw new ImportException(e);
} finally {
if(cardApi != null) {
cardApi.disconnect();
}
}
}
@Override
public String getKeystoreImportDescription(int account) {
return "Import the keystore from your Tapsigner by placing it on the card reader.";
}
@Override
public String getName() {
return "Tapsigner";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.TAPSIGNER;
}
}
@@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.io.db;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.Files;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
@@ -31,6 +30,7 @@ import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.util.*;
@@ -245,7 +245,7 @@ public class DbPersistence implements Persistence {
walletNodeDao.updateNodeAddressData(addressNode.getId(), addressNode.getAddressData());
}
Set<BlockTransactionHashIndex> txos = addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo)).collect(Collectors.toSet());
List<BlockTransactionHashIndex> txos = addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo)).collect(Collectors.toList());
List<Long> existingIds = txos.stream().map(Persistable::getId).filter(Objects::nonNull).collect(Collectors.toList());
referencedTxIds.addAll(txos.stream().map(BlockTransactionHash::getHash).collect(Collectors.toSet()));
@@ -315,6 +315,11 @@ public class DbPersistence implements Persistence {
}
}
if(dirtyPersistables.walletConfig) {
WalletConfigDao walletConfigDao = handle.attach(WalletConfigDao.class);
walletConfigDao.addOrUpdate(wallet, wallet.getWalletConfig());
}
if(dirtyPersistables.mixConfig) {
MixConfigDao mixConfigDao = handle.attach(MixConfigDao.class);
mixConfigDao.addOrUpdate(wallet, wallet.getMixConfig());
@@ -620,25 +625,26 @@ public class DbPersistence implements Persistence {
}
//Flyway does not support JPMS yet, so the migration files are extracted to a temp dir in order to avoid classloader encapsulation issues
private File getMigrationDir() {
File migrationDir = Files.createTempDir();
private File getMigrationDir() throws StorageException {
try {
File migrationDir = Files.createTempDirectory("sparrowfly").toFile();
String[] files = IOUtils.getResourceListing(DbPersistence.class, MIGRATION_RESOURCES_DIR);
for(String name : files) {
File targetFile = new File(migrationDir, name);
try(InputStream inputStream = DbPersistence.class.getResourceAsStream("/" + MIGRATION_RESOURCES_DIR + name)) {
if(inputStream != null) {
java.nio.file.Files.copy(inputStream, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
Files.copy(inputStream, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
} else {
log.error("Could not load resource at /" + MIGRATION_RESOURCES_DIR + name);
}
}
}
return migrationDir;
} catch(Exception e) {
log.error("Could not extract migration resources", e);
throw new StorageException("Could not extract migration resources", e);
}
return migrationDir;
}
private HikariDataSource getDataSource(Storage storage, String password) throws StorageException {
@@ -745,6 +751,13 @@ public class DbPersistence implements Persistence {
}
}
@Subscribe
public void walletConfigChanged(WalletConfigChangedEvent event) {
if(persistsFor(event.getWallet()) && event.getWallet().getWalletConfig() != null) {
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).walletConfig = true);
}
}
@Subscribe
public void walletMixConfigChanged(WalletMixConfigChangedEvent event) {
if(persistsFor(event.getWallet()) && event.getWallet().getMixConfig() != null) {
@@ -793,6 +806,7 @@ public class DbPersistence implements Persistence {
public Integer watchLast = null;
public final List<Entry> labelEntries = new ArrayList<>();
public final List<BlockTransactionHashIndex> utxoStatuses = new ArrayList<>();
public boolean walletConfig;
public boolean mixConfig;
public final Map<Sha256Hash, UtxoMixData> changedUtxoMixes = new HashMap<>();
public final Map<Sha256Hash, UtxoMixData> removedUtxoMixes = new HashMap<>();
@@ -812,6 +826,7 @@ public class DbPersistence implements Persistence {
"\nAddress labels:" + labelEntries.stream().filter(entry -> entry instanceof NodeEntry).map(entry -> ((NodeEntry)entry).getNode().toString() + " " + entry.getLabel()).collect(Collectors.toList()) +
"\nUTXO labels:" + labelEntries.stream().filter(entry -> entry instanceof HashIndexEntry).map(entry -> ((HashIndexEntry)entry).getHashIndex().toString()).collect(Collectors.toList()) +
"\nUTXO statuses:" + utxoStatuses +
"\nWallet config:" + walletConfig +
"\nMix config:" + mixConfig +
"\nUTXO mixes changed:" + changedUtxoMixes +
"\nUTXO mixes removed:" + removedUtxoMixes +
@@ -0,0 +1,36 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletConfig;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
public interface WalletConfigDao {
@SqlQuery("select id, iconData, userIcon, usePayNym from walletConfig where wallet = ?")
@RegisterRowMapper(WalletConfigMapper.class)
WalletConfig getForWalletId(Long id);
@SqlUpdate("insert into walletConfig (iconData, userIcon, usePayNym, wallet) values (?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertWalletConfig(byte[] iconData, boolean userIcon, boolean usePayNym, long wallet);
@SqlUpdate("update walletConfig set iconData = ?, userIcon = ?, usePayNym = ?, wallet = ? where id = ?")
void updateWalletConfig(byte[] iconData, boolean userIcon, boolean usePayNym, long wallet, long id);
default void addWalletConfig(Wallet wallet) {
if(wallet.getWalletConfig() != null) {
addOrUpdate(wallet, wallet.getWalletConfig());
}
}
default void addOrUpdate(Wallet wallet, WalletConfig walletConfig) {
if(walletConfig.getId() == null) {
long id = insertWalletConfig(walletConfig.getIconData(), walletConfig.isUserIcon(), walletConfig.isUsePayNym(), wallet.getId());
walletConfig.setId(id);
} else {
updateWalletConfig(walletConfig.getIconData(), walletConfig.isUserIcon(), walletConfig.isUsePayNym(), wallet.getId(), walletConfig.getId());
}
}
}
@@ -0,0 +1,21 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.wallet.WalletConfig;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class WalletConfigMapper implements RowMapper<WalletConfig> {
@Override
public WalletConfig map(ResultSet rs, StatementContext ctx) throws SQLException {
byte[] iconData = rs.getBytes("iconData");
boolean userIcon = rs.getBoolean("userIcon");
boolean usePayNym = rs.getBoolean("usePayNym");
WalletConfig walletConfig = new WalletConfig(iconData, userIcon, usePayNym);
walletConfig.setId(rs.getLong("id"));
return walletConfig;
}
}
@@ -1,10 +1,7 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.UtxoMixData;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.*;
import org.jdbi.v3.sqlobject.CreateSqlObject;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
@@ -33,6 +30,9 @@ public interface WalletDao {
@CreateSqlObject
DetachedLabelDao createDetachedLabelDao();
@CreateSqlObject
WalletConfigDao createWalletConfigDao();
@CreateSqlObject
MixConfigDao createMixConfigDao();
@@ -118,6 +118,7 @@ public interface WalletDao {
Map<String, String> detachedLabels = createDetachedLabelDao().getAll();
wallet.getDetachedLabels().putAll(detachedLabels);
wallet.setWalletConfig(createWalletConfigDao().getForWalletId(wallet.getId()));
wallet.setMixConfig(createMixConfigDao().getForWalletId(wallet.getId()));
Map<Sha256Hash, UtxoMixData> utxoMixes = createUtxoMixDataDao().getForWalletId(wallet.getId());
@@ -136,6 +137,7 @@ public interface WalletDao {
createWalletNodeDao().addWalletNodes(wallet);
createBlockTransactionDao().addBlockTransactions(wallet);
createDetachedLabelDao().clearAndAddAll(wallet);
createWalletConfigDao().addWalletConfig(wallet);
createMixConfigDao().addMixConfig(wallet);
createUtxoMixDataDao().addUtxoMixData(wallet);
} finally {
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.core.result.LinkedHashMapRowReducer;
@@ -8,6 +9,8 @@ import org.jdbi.v3.core.result.RowView;
import java.util.Map;
public class WalletNodeReducer implements LinkedHashMapRowReducer<Long, WalletNode> {
private static final BlockTransactionHashIndex INPUT_MARKER = new BlockTransactionHashIndex(Sha256Hash.ZERO_HASH, 0, null, null, 0, 0);
@Override
public void accumulate(Map<Long, WalletNode> map, RowView rowView) {
WalletNode walletNode = map.computeIfAbsent(rowView.getColumn("walletNode.id", Long.class), id -> rowView.getRow(WalletNode.class));
@@ -20,11 +23,18 @@ public class WalletNodeReducer implements LinkedHashMapRowReducer<Long, WalletNo
if(rowView.getColumn("blockTransactionHashIndex.node", Long.class) != null) {
BlockTransactionHashIndex blockTransactionHashIndex = rowView.getRow(BlockTransactionHashIndex.class);
if(rowView.getColumn("blockTransactionHashIndex.spentBy", Long.class) != null) {
BlockTransactionHashIndex spentBy = walletNode.getTransactionOutputs().stream().filter(ref -> ref.getId().equals(rowView.getColumn("blockTransactionHashIndex.spentBy", Long.class))).findFirst().orElseThrow();
BlockTransactionHashIndex spentBy = walletNode.getTransactionOutputs().stream().filter(ref -> ref.getId().equals(rowView.getColumn("blockTransactionHashIndex.spentBy", Long.class))).findFirst()
.orElseThrow(() -> new IllegalStateException("Cannot find transaction output for " + rowView.getColumn("blockTransactionHashIndex.spentBy", Long.class)));
blockTransactionHashIndex.setSpentBy(spentBy);
walletNode.getTransactionOutputs().remove(spentBy);
spentBy.setSpentBy(null);
}
if(!walletNode.getTransactionOutputs().add(blockTransactionHashIndex)) {
//Can only happen if we add an input with the same hash, index, height etc as an existing output
//Set a marker we will clear when adding the output
blockTransactionHashIndex.setSpentBy(INPUT_MARKER);
walletNode.getTransactionOutputs().add(blockTransactionHashIndex);
}
walletNode.getTransactionOutputs().add(blockTransactionHashIndex);
}
}
}
@@ -1,29 +1,37 @@
package com.sparrowwallet.sparrow.keystoreimport;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.sparrow.control.CardImportPane;
import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
import javafx.fxml.FXML;
import javafx.scene.control.Accordion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class HwAirgappedController extends KeystoreImportDetailController {
private static final Logger log = LoggerFactory.getLogger(HwAirgappedController.class);
@FXML
private Accordion importAccordion;
public void initializeView() {
List<KeystoreFileImport> importers = Collections.emptyList();
List<KeystoreFileImport> fileImporters = Collections.emptyList();
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
fileImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
}
for(KeystoreFileImport importer : importers) {
for(KeystoreFileImport importer : fileImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileKeystoreImportPane importPane = new FileKeystoreImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
@@ -32,6 +40,19 @@ public class HwAirgappedController extends KeystoreImportDetailController {
}
}
List<KeystoreCardImport> cardImporters = Collections.emptyList();
if(isReaderAvailable()) {
cardImporters = List.of(new Tapsigner());
}
for(KeystoreCardImport importer : cardImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) {
importAccordion.getPanes().add(importPane);
}
}
}
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
}
}

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