Compare commits

..
97 Commits
Author SHA1 Message Date
Craig Raw f8fa7f4cf2 add separate button to backup tapsigner without changing pin 2023-06-22 10:41:59 +02:00
Craig Raw bcbb531414 fix ui button update when stopping mixing on terminal 2023-06-22 09:10:52 +02:00
Craig Raw 2deab05c45 remove path from non-batched electrum server requests 2023-06-22 08:21:57 +02:00
Craig Raw c7923300c6 update kmp-tor to v1.4.3, fix storage tests 2023-06-22 07:54:48 +02:00
Craig Raw c4651025be format fee rates in broadcast errors using unit format 2023-06-21 16:42:57 +02:00
Craig Raw 2897f88c8b fix unit format switching on send tab 2023-06-21 15:47:21 +02:00
Craig Raw ee20a6980b fix incorrect hours check for 24h mempool fee rates toggle 2023-06-21 11:40:04 +02:00
Craig Raw 721c446fa8 update seedsigner help text 2023-06-21 10:36:34 +02:00
Craig Raw 7c43ee7208 add context menu options to date column in utxos table to freeze utxo and view transaction 2023-06-21 09:33:42 +02:00
Craig Raw c6ea37e081 add 24h mempool fees chart toggle 2023-06-21 08:33:05 +02:00
Craig Raw 171bf24133 optimize fetching mempool entries for fee histogram when connected to bitcoin core, fix and improve mempool fee rates chart 2023-06-20 16:15:19 +02:00
Craig Raw 3242f00812 improve performance on large wallets with high address reuse 2023-06-15 12:19:09 +02:00
Craig Raw 15500b6535 highlight csv download buttons 2023-06-13 14:13:44 +02:00
Craig Raw 700c880b92 improve error message on broadcasting a tx with fee rate below the purge rate for the connected mempool 2023-06-11 10:50:19 +02:00
Craig Raw 7f2c07c918 set all witness utxos on psbt inputs before attempting to sign sweep tx 2023-06-08 11:07:32 +02:00
Craig Raw 0745d21761 fix export to specter desktop without any wallet history 2023-06-08 08:58:43 +02:00
Craig Raw 50aa9b4dcb ensure canonical ordering of key expressions in multisig descriptor qr 2023-06-07 10:21:29 +02:00
Craig Raw fe50cb845e log batch errors getting fee estimates from server 2023-06-05 11:49:10 +02:00
Craig Raw f4b9807285 widen mnemonic grid dialog to accommodate word numbers 2023-06-04 10:02:55 +02:00
Craig Raw f534beb624 add additional rbf tx inputs if needed as required fee is increased 2023-06-01 15:31:15 +02:00
Craig Raw 719cfaa906 support airgapped keystore import of a tapsigner with a custom derivation path 2023-05-31 15:50:06 +02:00
Craig Raw 98d9a6882b look for supported cards across all connected card terminals 2023-05-31 14:25:45 +02:00
Craig Raw dbbeaf67b6 support spendable property on utxos in bip329 wallet label imports and exports 2023-05-31 10:42:32 +02:00
Craig Raw 2a542bb8b9 avoid multiple selection on border wallets grid, display word number, clarify recovery phrase purpose on pdf 2023-05-30 14:01:45 +02:00
Craig Raw 29b630f6bf update to hummingbird v1.6.7 to support pair path components and unique part progress indicator 2023-05-29 17:20:43 +02:00
Craig Raw 3aa00076c6 trim leading and trailing whitespace from pay to address field 2023-05-25 16:15:38 +02:00
Craig Raw 3cf99961d3 improve error message when broadcasting an rbf transaction with insufficient fee, indicate minimum required fee if available 2023-05-25 14:48:32 +02:00
Craig Raw 742727d6f2 add ctrl/cmd+b keyboard shortcut for quickly switching from btc to satoshis and back 2023-05-25 13:22:44 +02:00
Craig Raw 5d99eee89a invert cropped frame and scan for inverted qrs 2023-05-25 12:29:43 +02:00
Craig Raw b52be27a99 support scanning text qr containing seed words 2023-05-15 15:49:39 -05:00
Craig Raw e68f177e4a bump to v1.7.7 2023-04-14 18:31:10 -06:00
Craig Raw e0a6626650 add further guidance when regenerating a border wallets grid 2023-04-14 16:41:47 -06:00
Craig Raw 1d8888bb14 fix button placement on border wallets dialog in windows 2023-04-14 15:59:24 -06:00
Craig Raw d0958b7936 clear border wallets selected cells on grid initialization 2023-04-14 13:26:29 -06:00
Craig Raw d9d316a627 bump to v1.7.6 2023-04-14 10:25:08 -06:00
Craig Raw 0270910b74 update specter desktop wallet import 2023-04-14 08:29:24 -06:00
Craig Raw 432e0642ca cormorant: improve error message when core wallet support is disabled 2023-04-14 07:50:06 -06:00
Craig Raw c7ab8e4601 support broadcasting via mempool.space when using signet 2023-04-14 07:41:00 -06:00
Craig Raw 1a46f8a643 preserve order of cell selection in border wallets grid 2023-04-14 06:36:34 -06:00
Craig Raw 04145bde74 add border wallets number grid support 2023-04-06 14:57:56 +02:00
Craig Raw 483e4c8f38 always show tapsigner in airgapped import options 2023-04-05 08:24:18 +02:00
Craig Raw fe4468d49d bump to v1.7.5 2023-04-05 08:10:36 +02:00
Craig Raw a4e9ef989d darker dark theme and other styling improvements 2023-04-04 12:33:12 +02:00
Craig Raw 4ed8550f1d fix dark themed spreadsheets 2023-04-04 09:22:49 +02:00
Craig Raw 4bec71e7c4 read name from ur:crypto-hdkey and set keystore label if present 2023-03-29 16:00:32 +02:00
Craig Raw 961fd94dd6 allow stowaway counterparty to spend postmix utxos but receive to master wallet 2023-03-29 15:17:19 +02:00
Craig Raw 7915bbfa47 save border wallet pdf when generating a new recovery phrase 2023-03-29 12:00:04 +02:00
Craig Raw 49e70e8e9b fix accidentally making keystore fields editable on loading of single account non-watchonly wallets 2023-03-29 09:22:45 +02:00
Craig Raw 6063b02113 generate border wallets grid from seed words 2023-03-29 08:21:15 +02:00
Craig Raw faa5a11c94 update tor to 0.4.7.13 using kmp-tor library 2023-03-28 14:38:20 +02:00
Craig Raw acab50cdcd add diynodes.com public mainnet electrum server 2023-03-28 09:15:38 +02:00
Craig Raw 4d7d897e06 add port of deterministic prng for border wallets word shuffle 2023-03-28 08:37:20 +02:00
Craig Raw af532e7fc9 import seed via border wallets grid pattern 2023-03-27 11:00:32 +02:00
Craig Raw fd2b383dbc autosuggest possible words for the last word in a bip39 seed 2023-03-22 15:50:12 +02:00
Craig Raw 98b33e184e use whirlpool premix priority slider to change mixfeetarget, display warning if chosen fee rate is much lower than normal priority 2023-03-21 09:34:19 +02:00
Craig Raw 3bc7c7473a fix error initializing whirlpool on new wallet without a passphrase 2023-03-21 08:34:06 +02:00
Craig Raw 4f6981b869 change wallet gap limit and subscribe to new addresses if an address beyond gap limit range is requested 2023-03-15 08:44:53 +02:00
Craig Raw 258d46a253 support saving tapsigner backup as binary file 2023-03-09 13:40:57 +02:00
Craig Raw 40a3eb5d4f followup, minor tidying 2023-03-09 11:01:25 +02:00
craigrawandGitHub 35965235f3 Merge pull request #860 from wazint/master
Add context menu to copy amount values from amount cells
2023-03-09 10:55:36 +02:00
Craig Raw 368b24ea3b cormorant: handle empty (0 block only) chains 2023-03-09 10:09:52 +02:00
Craig Raw 107b5ba36c show psbt qrs without non witness utxo entries for segwit signing wallets 2023-03-07 13:17:33 +02:00
Craig Raw 84978a3d5d use different addresses when sending batched payments to the same paynym 2023-03-06 12:30:26 +02:00
Craig Raw dd3b980c36 strip path from server url when determining host and port 2023-03-06 10:40:46 +02:00
wazint b9a553abf2 don't initialise a ContextMenu for each cell update 2023-03-04 20:30:47 +02:00
Craig Raw 48b3dbc353 bump to v1.7.4 2023-03-01 10:43:36 +02:00
Craig Raw fb40d991bb replace default textinput dialog with custom textfield dialog 2023-03-01 08:14:25 +02:00
Craig Raw 5fe6a7196a use uppercase to encode pdf output descriptor qr 2023-02-28 16:52:42 +02:00
Craig Raw f06b859c82 extend request timeout for paynym api 2023-02-28 14:37:46 +02:00
Craig Raw 80dd59928e upgrade jlink plugin to support java 19 2023-02-28 14:21:57 +02:00
Craig Raw f22f76464a add option ot disable block explorer 2023-02-28 13:59:45 +02:00
Craig Raw dfe1f16495 configure a block explorer url, and open a txid in the configured block explorer 2023-02-28 13:19:24 +02:00
Craig Raw 90a9030ecb allow crypto-output qr scanning from wallet import dialog 2023-02-27 14:03:12 +02:00
Craig Raw 4ab33a373c reduce log level for no card reader errors 2023-02-27 12:36:41 +02:00
Craig Raw 10e751d6e1 upgrade to hwi 2.2.1 2023-02-27 12:18:50 +02:00
Craig Raw 5f40669af7 request confirmation before enabling use of a bip39 passphrase 2023-02-27 09:11:06 +02:00
wazint 97b4ed48db add context menu to copy amount values from amount cells 2023-02-24 16:55:40 +02:00
Craig Raw 9fc096569a add minfeerate parameter to bip78 payjoin urls 2023-02-23 12:53:12 +02:00
Craig Raw 41636f7152 support encrypted bip129 wallet imports 2023-02-23 12:17:41 +02:00
Craig Raw fc5d48de6f bip129 round 2 support (wallet import and export) 2023-02-23 12:02:06 +02:00
Craig Raw 2a7f14a4ed bip129 round 1 support with optional signing of bsms keystore exports 2023-02-22 10:22:04 +02:00
Craig Raw 1cb6778502 bump required java version to 18 2023-02-20 11:56:34 +02:00
Craig Raw 7f254e763d fix keystore encryption issue when changing the password on a wallet with freshly added accounts 2023-02-17 09:10:40 +02:00
Craig Raw e0ff42b6a4 terminal: add lock menu item to all wallets with a password 2023-02-17 08:37:10 +02:00
craigrawandGitHub 88fc8f5017 Merge pull request #843 from secondl1ght/master
add StartupWMClass to linux .desktop file
2023-02-15 08:00:36 +02:00
secondl1ghtandGitHub d7072928de add StartupWMClass to linux .desktop file 2023-02-14 21:22:41 -07:00
Craig Raw 0cc9ddba05 read and throw hwi error stream if stdout empty 2023-02-14 09:57:23 +02:00
Craig Raw e3799cd0a8 fix error receiving a stowaway to postmix by reverting to master wallet 2023-02-13 18:27:21 +02:00
Craig Raw ea6b30326e fix taproot signature hash for single | anyonecanpay 2023-02-13 17:35:17 +02:00
Craig Raw 38768885e2 show hwi signature verification errors, display strings encoded into scripts 2023-02-13 16:36:33 +02:00
Craig Raw c360177c31 use default ports for bitcoin core if absent, fix ux on changing port for an aliased server 2023-02-12 13:06:21 +02:00
Craig Raw e88ea0bac1 improve ux of bip39 wallet discovery on bitcoin core 2023-02-12 11:48:46 +02:00
Craig Raw a66b36c59c cormorant: switch bitcoind client from named to array parameters to support btc-rpc-proxy 2023-02-11 14:28:10 +02:00
Craig Raw fb3b674b65 recompile secp256k1 on osx 10.13.6 2023-02-10 09:23:02 +02:00
Craig Raw eff0e201f3 cormorant: only use proxy when connecting to onion addresses 2023-02-10 09:12:09 +02:00
Craig Raw 58d10cbba4 v1.7.3 2023-02-09 18:30:28 +02:00
Craig Raw 29bce8a9bc revert to ubuntu 20.04 runner 2023-02-09 17:34:19 +02:00
150 changed files with 3665 additions and 586 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
os: [windows-2022, ubuntu-20.04, macos-12]
steps:
- uses: actions/checkout@v3
with:
+2 -2
View File
@@ -16,7 +16,7 @@ or for those without SSH credentials:
`git clone --recursive https://github.com/sparrowwallet/sparrow.git`
In order to build, Sparrow requires Java 17 or higher to be installed.
In order to build, Sparrow requires Java 18 or higher to be installed.
The release binaries are built with [Eclipse Temurin 18.0.1+10](https://github.com/adoptium/temurin18-binaries/releases/tag/jdk-18.0.1%2B10).
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
@@ -44,7 +44,7 @@ If you prefer to run Sparrow directly from source, it can be launched from withi
`./sparrow`
Java 17 or higher must be installed.
Java 18 or higher must be installed.
## Configuration
+139 -40
View File
@@ -4,10 +4,10 @@ plugins {
id 'application'
id 'org-openjfx-javafxplugin'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.25.0'
id 'org.beryx.jlink' version '2.26.0'
}
def sparrowVersion = '1.7.2'
def sparrowVersion = '1.7.7'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -23,6 +23,19 @@ if(System.getProperty("os.arch") == "aarch64") {
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
def vTor = '4.7.13-4'
def vKmpTor = '1.4.3'
def kmpOs = osName
if(os.macOsX) {
kmpOs = "macos"
} else if(os.windows) {
kmpOs = "mingw"
}
def kmpArch = "x64"
if(System.getProperty("os.arch") == "aarch64") {
kmpArch = "arm64"
}
group "com.sparrowwallet"
version "${sparrowVersion}"
@@ -76,7 +89,7 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.3') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('com.sparrowwallet:hummingbird:1.6.7')
implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
@@ -84,10 +97,15 @@ dependencies {
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}${targetName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
implementation "io.matthewnelson.kotlin-components:kmp-tor:${vTor}-${vKmpTor}"
if(kmpOs == "linux" && kmpArch == "arm64") {
implementation("com.sparrowwallet.kmp-tor-binary:kmp-tor-binary-${kmpOs}${kmpArch}-jvm:${vTor}")
} else {
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-${kmpOs}${kmpArch}:${vTor}")
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-extract:${vTor}")
implementation("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager:${vKmpTor}")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.1')
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
@@ -103,12 +121,12 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.34')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.36')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.27')
implementation('com.github.librepdf:openpdf:1.3.30')
implementation('com.googlecode.lanterna:lanterna:3.1.1')
implementation('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2')
@@ -160,9 +178,11 @@ run {
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow"]
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
@@ -208,6 +228,7 @@ jlink {
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.io=com.google.gson",
@@ -219,7 +240,8 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.databind",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor"]
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.windows) {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
@@ -401,11 +423,6 @@ extraJavaModuleInfo {
requires('java.xml')
requires('java.logging')
}
module('kotlin-logging-1.5.4.jar', 'io.github.microutils.kotlin.logging', '1.5.4') {
exports('mu')
requires('kotlin.stdlib')
requires('org.slf4j')
}
module('failureaccess-1.0.1.jar', 'failureaccess', '1.0.1') {
exports('com.google.common.util.concurrent.internal')
}
@@ -481,13 +498,6 @@ extraJavaModuleInfo {
requires('javafx.graphics')
}
module('jai-imageio-core-1.4.0.jar', 'com.github.jai.imageio.jai.imageio.core', '1.4.0')
module('kotlin-stdlib-jdk8-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk8', '1.5.20')
module('kotlin-stdlib-jdk7-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk7', '1.5.20')
module('kotlin-stdlib-1.5.20.jar', 'kotlin.stdlib', '1.5.20') {
exports('kotlin')
exports('kotlin.jvm')
exports('kotlin.collections')
}
module('hummingbird-1.6.3.jar', 'com.sparrowwallet.hummingbird', '1.6.3') {
exports('com.sparrowwallet.hummingbird')
exports('com.sparrowwallet.hummingbird.registry')
@@ -498,7 +508,7 @@ extraJavaModuleInfo {
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
}
module('nightjar-0.2.34.jar', 'com.sparrowwallet.nightjar', '0.2.34') {
module('nightjar-0.2.36.jar', 'com.sparrowwallet.nightjar', '0.2.36') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -595,29 +605,118 @@ extraJavaModuleInfo {
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')
requires('com.github.JesusMcCloud.jtorctl')
module("kmp-tor-jvm-${vKmpTor}.jar", 'kmp.tor.jvm', "${vTor}-${vKmpTor}") {
exports('io.matthewnelson.kmp.tor')
requires('kmp.tor.binary.extract.jvm')
requires('kmp.tor.manager.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('commons.compress')
requires('org.tukaani.xz')
requires('kotlinx.coroutines.core')
requires('java.management')
requires('io.github.microutils.kotlin.logging')
}
module('jtorctl-1.5.jar', 'com.github.JesusMcCloud.jtorctl', '1.5') {
exports('net.freehaven.tor.control')
if(kmpOs == "linux" && kmpArch == "arm64") {
module("kmp-tor-binary-${kmpOs}${kmpArch}-jvm-${vTor}.jar", "kmp.tor.binary.${kmpOs}${kmpArch}", "${vTor}") {
exports("io.matthewnelson.kmp.tor.resource.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
} else {
module("kmp-tor-binary-${kmpOs}${kmpArch}-jvm-${vTor}.jar", "kmp.tor.binary.${kmpOs}${kmpArch}", "${vTor}") {
exports("io.matthewnelson.kmp.tor.binary.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
}
module('commons-compress-1.18.jar', 'commons.compress', '1.18') {
exports('org.apache.commons.compress')
requires('org.tukaani.xz')
module("kmp-tor-binary-extract-jvm-${vTor}.jar", 'kmp.tor.binary.extract.jvm', "${vTor}") {
exports('io.matthewnelson.kmp.tor.binary.extract')
exports('io.matthewnelson.kmp.tor.binary.extract.internal')
requires('kotlin.stdlib')
requires("kmp.tor.binary.${kmpOs}${kmpArch}")
requires('kmp.tor.binary.geoip.jvm')
}
module('xz-1.6.jar', 'org.tukaani.xz', '1.6') {
exports('org.tukaani.xz')
module("kmp-tor-manager-jvm-${vKmpTor}.jar", 'kmp.tor.manager.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.manager')
exports('io.matthewnelson.kmp.tor.manager.util')
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core')
requires('kotlinx.atomicfu')
requires('kmp.tor.controller.jvm')
requires('kmp.tor.common.jvm')
}
module('jsocks-1.0.jar', 'com.github.ravn.jsocks', '1.0') {
exports('com.runjva.sourceforge.jsocks.protocol')
requires('org.slf4j')
module("kmp-tor-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.manager.common')
exports('io.matthewnelson.kmp.tor.manager.common.event')
exports('io.matthewnelson.kmp.tor.manager.common.state')
requires('kmp.tor.controller.common.jvm')
requires('kmp.tor.common.jvm')
requires('kotlin.stdlib')
}
module("kmp-tor-controller-common-jvm-${vKmpTor}.jar", 'kmp.tor.controller.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.controller.common.config')
exports('io.matthewnelson.kmp.tor.controller.common.file')
exports('io.matthewnelson.kmp.tor.controller.common.control')
exports('io.matthewnelson.kmp.tor.controller.common.control.usecase')
exports('io.matthewnelson.kmp.tor.controller.common.events')
exports('io.matthewnelson.kmp.tor.controller.common.exceptions')
requires('kmp.tor.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
}
module("kmp-tor-common-jvm-${vKmpTor}.jar", 'kmp.tor.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.common.address')
requires('parcelize.jvm')
requires('kotlin.stdlib')
}
module("kmp-tor-controller-jvm-${vKmpTor}.jar", 'kmp.tor.controller.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.controller.internal.controller')
requires('kmp.tor.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlinx.coroutines.core')
requires('kotlin.stdlib')
requires('kotlinx.atomicfu')
requires('encoding.core.jvm')
requires('encoding.base16.jvm')
}
module("kmp-tor-ext-callback-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.common')
}
module("kmp-tor-ext-callback-manager-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager')
requires('kmp.tor.manager.jvm')
requires('kmp.tor.ext.callback.common.jvm')
requires('kmp.tor.ext.callback.manager.common.jvm')
requires('kmp.tor.ext.callback.controller.common.jvm')
requires('kmp.tor.manager.common.jvm')
requires('kmp.tor.controller.common.jvm')
requires('kotlin.stdlib')
requires('kotlinx.coroutines.core')
}
module("kmp-tor-ext-callback-manager-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.manager.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.manager.common')
requires('kmp.tor.ext.callback.controller.common.jvm')
}
module("kmp-tor-ext-callback-controller-common-jvm-${vKmpTor}.jar", 'kmp.tor.ext.callback.controller.common.jvm', "${vKmpTor}") {
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control')
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control.usecase')
}
module("kmp-tor-binary-geoip-jvm-${vTor}.jar", 'kmp.tor.binary.geoip.jvm', "${vTor}") {
exports('io.matthewnelson.kmp.tor.binary.geoip')
exports('kmptor')
}
module("base16-jvm-2.0.0.jar", 'encoding.base16.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.base16')
requires('encoding.core.jvm')
requires('kotlin.stdlib')
}
module("base32-jvm-2.0.0.jar", 'encoding.base32.jvm', "2.0.0")
module("base64-jvm-2.0.0.jar", 'encoding.base64.jvm', "2.0.0")
module("core-jvm-2.0.0.jar", 'encoding.core.jvm', "2.0.0") {
exports('io.matthewnelson.encoding.core')
requires('kotlin.stdlib')
}
module("parcelize-jvm-0.1.2.jar", 'parcelize.jvm', "0.1.2") {
exports('io.matthewnelson.component.parcelize')
}
module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0')
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
+1 -1
View File
@@ -82,7 +82,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="1.7.2"
GIT_TAG="1.7.6"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: d48054ac6b...a8df17ff5e
@@ -6,4 +6,5 @@ Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Finance;Network;
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
StartupWMClass=Sparrow
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.7.2</string>
<string>1.7.7</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -328,6 +328,9 @@ public class AppController implements Initializable {
final BitcoinUnit selectedUnit = unit;
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
Optional<Toggle> otherUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle ->
(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(selectedUnit) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (selectedUnit == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData())))).findFirst();
otherUnitToggle.ifPresent(toggle -> ((RadioMenuItem)toggle).setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN)));
UnitFormat format = Config.get().getUnitFormat();
if(format == null) {
@@ -753,7 +756,7 @@ public class AppController implements Initializable {
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
writer.flush();
} else {
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs));
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs, true));
}
} catch(IOException e) {
log.error("Error saving PSBT", e);
@@ -1073,7 +1076,18 @@ public class AppController implements Initializable {
Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get();
if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
List<WalletTabData> walletTabData = getOpenWalletTabData();
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
if(optNewWalletForm.isPresent()) {
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
selectTab(optNewWalletForm.get().getWallet());
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
}
}
@@ -1117,7 +1131,7 @@ public class AppController implements Initializable {
}
private void addImportedWallet(Wallet wallet) {
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName());
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getBirthDate());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
if(optNameAndBirthDate.isPresent()) {
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
@@ -2786,6 +2800,14 @@ public class AppController implements Initializable {
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
Optional<Toggle> selectedToggle = bitcoinUnit.getToggles().stream().filter(toggle -> event.getBitcoinUnit().equals(toggle.getUserData())).findFirst();
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
bitcoinUnit.getToggles().forEach(toggle -> {
RadioMenuItem menuItem = (RadioMenuItem)toggle;
if(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(event.getBitcoinUnit()) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (event.getBitcoinUnit() == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData()))) {
menuItem.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN));
} else {
menuItem.setAccelerator(null);
}
});
}
@Subscribe
@@ -50,7 +50,6 @@ import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -252,7 +251,7 @@ public class AppServices {
}
if(Tor.getDefault() != null) {
Tor.getDefault().shutdown();
Tor.getDefault().getTorManager().destroy(true, success -> {});
}
}
@@ -414,23 +413,6 @@ public class AppServices {
EventManager.get().post(new TorReadyStatusEvent());
});
torService.setOnFailed(workerStateEvent -> {
Throwable exception = workerStateEvent.getSource().getException();
if(exception instanceof TorServerAlreadyBoundException) {
String proxyServer = Config.get().getProxyServer();
if(proxyServer == null || proxyServer.equals("")) {
proxyServer = "localhost:9050";
Config.get().setProxyServer(proxyServer);
}
if(proxyServer.equals("localhost:9050") || proxyServer.equals("127.0.0.1:9050")) {
Config.get().setUseProxy(true);
torService.cancel();
restartServices();
EventManager.get().post(new TorExternalStatusEvent());
return;
}
}
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
});
@@ -490,8 +472,7 @@ public class AppServices {
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
} else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
proxy = Tor.getDefault().getProxy();
}
//Setting new proxy authentication credentials will force a new Tor circuit to be created
@@ -546,7 +527,7 @@ public class AppServices {
public static HostAndPort getTorProxy() {
return AppServices.isTorRunning() ?
HostAndPort.fromParts("localhost", TorService.PROXY_PORT) :
Tor.getDefault().getProxyHostAndPort() :
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer()));
}
@@ -688,6 +669,10 @@ public class AppServices {
}
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
if(rateSizes.isEmpty()) {
return;
}
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
if(mempoolHistogram.isEmpty()) {
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
@@ -811,6 +796,24 @@ public class AppServices {
}
}
public static void openBlockExplorer(String txid) {
if(Config.get().isBlockExplorerDisabled()) {
return;
}
Server blockExplorer = Config.get().getBlockExplorer() == null ? BlockExplorer.MEMPOOL_SPACE.getServer() : Config.get().getBlockExplorer();
String url = blockExplorer.getUrl();
if(url.contains("{0}")) {
url = url.replace("{0}", txid);
} else {
if(Network.get() != Network.MAINNET) {
url += "/" + Network.get().getName();
}
url += "/tx/" + txid;
}
AppServices.get().getApplication().getHostServices().showDocument(url);
}
static void parseFileUriArguments(List<String> fileUriArguments) {
for(String fileUri : fileUriArguments) {
try {
@@ -1023,8 +1026,6 @@ public class AppServices {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
@@ -1047,6 +1048,10 @@ public class AppServices {
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
}
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
@@ -25,7 +25,7 @@ public abstract class BaseController {
scriptArea.setMouseOverTextDelay(Duration.ofMillis(150));
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
TwoDimensional.Position position = scriptArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
if(position.getMajor() % 2 == 0) {
if(position.getMajor() % 2 == 0 && scriptArea.getScript().getChunks().size() > position.getMajor() / 2) {
ScriptChunk hoverChunk = scriptArea.getScript().getChunks().get(position.getMajor()/2);
if(!hoverChunk.isOpCode()) {
Point2D pos = e.getScreenPosition();
@@ -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.2";
public static final String APP_VERSION = "1.7.7";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -1,17 +0,0 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.LogHandler;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;
public class TorLogHandler implements LogHandler {
private static final Logger log = LoggerFactory.getLogger(TorLogHandler.class);
@Override
public void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData) {
log.debug(message);
EventManager.get().post(new TorStatusEvent(message));
}
}
@@ -26,6 +26,10 @@ import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,11 +37,13 @@ import javax.smartcardio.CardException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
public class CardImportPane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
private final KeystoreCardImport importer;
private final List<ChildNumber> derivation;
private List<ChildNumber> derivation;
protected Button importButton;
private final SimpleStringProperty pin = new SimpleStringProperty("");
@@ -62,9 +68,15 @@ public class CardImportPane extends TitledDescriptionPane {
}
private void importCard() {
if(!isReaderAvailable()) {
setError("No reader", "No card reader was detected.");
importButton.setDisable(false);
return;
}
if(pin.get().length() < 6) {
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
setContent(getPinEntry());
setContent(getPinAndDerivationEntry());
showHideLink.setVisible(false);
setExpanded(true);
importButton.setDisable(false);
@@ -98,7 +110,7 @@ public class CardImportPane extends TitledDescriptionPane {
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
if(rootCause instanceof CardAuthorizationException) {
setError(rootCause.getMessage(), null);
setContent(getPinEntry());
setContent(getPinAndDerivationEntry());
} else {
log.error("Error importing keystore from card", event.getSource().getException());
setError("Import Error", rootCause.getMessage());
@@ -161,6 +173,13 @@ public class CardImportPane extends TitledDescriptionPane {
return contentBox;
}
private Node getPinAndDerivationEntry() {
VBox vBox = new VBox();
vBox.getChildren().add(getPinEntry());
vBox.getChildren().add(getDerivationEntry());
return vBox;
}
private Node getPinEntry() {
VBox vBox = new VBox();
@@ -175,7 +194,7 @@ public class CardImportPane extends TitledDescriptionPane {
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(pinField);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPadding(new Insets(10, 30, 0, 30));
contentBox.setPrefHeight(50);
vBox.getChildren().add(contentBox);
@@ -183,6 +202,51 @@ public class CardImportPane extends TitledDescriptionPane {
return vBox;
}
private Node getDerivationEntry() {
VBox vBox = new VBox();
CheckBox checkBox = new CheckBox("Use Custom Derivation");
Label customLabel = new Label("Derivation:");
TextField customDerivation = new TextField(KeyDerivation.writePath(derivation));
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(customDerivation, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
customDerivation.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue.isEmpty() || !KeyDerivation.isValid(newValue)) {
importButton.setDisable(true);
} else {
importButton.setDisable(false);
derivation = KeyDerivation.parsePath(newValue);
}
});
checkBox.managedProperty().bind(checkBox.visibleProperty());
customLabel.managedProperty().bind(customLabel.visibleProperty());
customDerivation.managedProperty().bind(customDerivation.visibleProperty());
customLabel.visibleProperty().bind(checkBox.visibleProperty().not());
customDerivation.visibleProperty().bind(checkBox.visibleProperty().not());
checkBox.selectedProperty().addListener((observable, oldValue, newValue) -> {
checkBox.setVisible(false);
});
HBox derivationBox = new HBox();
derivationBox.setAlignment(Pos.CENTER_LEFT);
derivationBox.setSpacing(20);
derivationBox.getChildren().addAll(checkBox, customLabel, customDerivation);
derivationBox.setPadding(new Insets(10, 30, 10, 30));
derivationBox.setPrefHeight(50);
vBox.getChildren().addAll(derivationBox);
return vBox;
}
public static class CardInitializationService extends Service<Void> {
private final KeystoreCardImport cardImport;
private final String pin;
@@ -22,20 +22,25 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
private final CheckBox backupFirst;
private final ButtonType okButtonType;
public CardPinDialog() {
public CardPinDialog(boolean backupOnly) {
this.existingPin = new ViewPasswordField();
this.newPin = new ViewPasswordField();
this.newPinConfirm = new ViewPasswordField();
this.backupFirst = new CheckBox();
if(backupOnly) {
newPin.textProperty().bind(existingPin.textProperty());
newPinConfirm.textProperty().bind(existingPin.textProperty());
}
final DialogPane dialogPane = getDialogPane();
setTitle("Change Card PIN");
dialogPane.setHeaderText("Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
setTitle(backupOnly ? "Backup Card" : "Change Card PIN");
dialogPane.setHeaderText(backupOnly ? "Enter the current card PIN." : "Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(260);
dialogPane.setPrefHeight(backupOnly ? 135 : 260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
@@ -63,7 +68,12 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
backupField.setText("Backup First:");
backupField.getInputs().add(backupFirst);
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
if(backupOnly) {
fieldset.getChildren().addAll(currentField);
} else {
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
}
form.getChildren().add(fieldset);
dialogPane.setContent(form);
@@ -75,7 +85,7 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
});
okButtonType = new javafx.scene.control.ButtonType("Change", ButtonBar.ButtonData.OK_DONE);
okButtonType = new javafx.scene.control.ButtonType(backupOnly ? "Backup" : "Change", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
okButton.setPrefWidth(130);
@@ -4,15 +4,16 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.Region;
import javafx.util.Duration;
import org.controlsfx.tools.Platform;
@@ -21,6 +22,7 @@ import java.text.DecimalFormat;
class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsListener {
private final CoinTooltip tooltip;
private final CoinContextMenu contextMenu;
private IntegerProperty confirmationsProperty;
@@ -28,6 +30,7 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
super();
tooltip = new CoinTooltip();
tooltip.setShowDelay(Duration.millis(500));
contextMenu = new CoinContextMenu();
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
getStyleClass().add("number-field");
@@ -42,6 +45,7 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
setText(null);
setGraphic(null);
setTooltip(null);
setContextMenu(null);
} else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
@@ -62,6 +66,8 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
setText(satsValue);
}
setTooltip(tooltip);
contextMenu.updateAmount(amount);
setContextMenu(contextMenu);
if(entry instanceof TransactionEntry transactionEntry) {
tooltip.showConfirmations(transactionEntry.confirmationsProperty());
@@ -155,4 +161,36 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
}
}
}
private static class CoinContextMenu extends ContextMenu {
private Number amount;
public void updateAmount(Number amount) {
if(amount.equals(this.amount)) {
return;
}
this.amount = amount;
getItems().clear();
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
copySatsValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(amount.toString());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyBtcValue = new MenuItem("Copy Value in BTC");
copyBtcValue.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
content.putString(format.formatBtcValue(amount.longValue()));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
}
}
}
@@ -12,7 +12,7 @@ import java.util.regex.Pattern;
public class CoinTextFormatter extends TextFormatter<String> {
public CoinTextFormatter(UnitFormat unitFormat) {
super(new CoinFilter(unitFormat));
super(new CoinFilter(unitFormat == null ? UnitFormat.DOT : unitFormat));
}
public UnitFormat getUnitFormat() {
@@ -29,7 +29,7 @@ public class CoinTextFormatter extends TextFormatter<String> {
private final Pattern coinValidation;
public CoinFilter(UnitFormat unitFormat) {
this.unitFormat = unitFormat == null ? UnitFormat.DOT : unitFormat;
this.unitFormat = unitFormat;
this.coinFormat = new DecimalFormat("###,###.########", unitFormat.getDecimalFormatSymbols());
this.coinValidation = Pattern.compile("[\\d" + Pattern.quote(unitFormat.getGroupingSeparator()) + "]*(" + Pattern.quote(unitFormat.getDecimalSeparator()) + "\\d{0,8})?");
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
@@ -12,6 +11,8 @@ import javafx.util.Duration;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import static com.sparrowwallet.sparrow.control.EntryCell.HashIndexEntryContextMenu;
public class DateCell extends TreeTableCell<Entry, Entry> {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
@@ -36,11 +37,11 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")));
setContextMenu(null);
setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), utxoEntry));
} else if(utxoEntry.getHashIndex().getDate() != null) {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
setContextMenu(new DateContextMenu(getTreeTableView(), utxoEntry, date));
} else {
setText("Unknown");
setContextMenu(null);
@@ -56,8 +57,10 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
}
}
private static class DateContextMenu extends ContextMenu {
public DateContextMenu(String date, BlockTransactionHashIndex reference) {
private static class DateContextMenu extends HashIndexEntryContextMenu {
public DateContextMenu(TreeTableView<Entry> treeTableView, UtxoEntry utxoEntry, String date) {
super(treeTableView, utxoEntry);
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -70,7 +73,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
copyHeight.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(reference.getHeight() > 0 ? Integer.toString(reference.getHeight()) : "Mempool");
content.putString(utxoEntry.getHashIndex().getHeight() > 0 ? Integer.toString(utxoEntry.getHashIndex().getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content);
});
@@ -245,8 +245,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
double vSize = tx.getVirtualSize();
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
//Remove any UTXOs created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
//Remove any UTXOs that are frozen or created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> utxo.getStatus() == Status.FROZEN || ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
//If there is insufficient change output, include another random UTXO so the fee can be increased
@@ -537,6 +537,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
getItems().add(createCpfp);
}
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
hide();
@@ -558,6 +565,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -582,7 +595,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(viewTransaction, copyDate, copyTxid, copyHeight);
getItems().addAll(viewTransaction, openBlockExplorer, copyDate, copyTxid, copyHeight);
}
}
@@ -678,7 +691,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
}
private static class HashIndexEntryContextMenu extends ContextMenu {
static class HashIndexEntryContextMenu extends ContextMenu {
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -162,8 +162,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
setError("Import Error", e.getMessage());
}
} else if(result.outputDescriptor != null) {
wallets = List.of(result.outputDescriptor.toKeystoreWallet(null));
try {
wallets = List.of(result.outputDescriptor.toWallet());
importFile(importer.getName(), null, null);
} catch(ImportException e) {
log.error("Error importing QR", e);
@@ -193,6 +193,10 @@ public abstract class FileImportPane extends TitledDescriptionPane {
}
}
protected List<Wallet> getScannedWallets() {
return wallets;
}
protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException {
if(wallets != null) {
for(Wallet wallet : wallets) {
@@ -215,7 +219,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private Node getPasswordEntry(File file) {
CustomPasswordField passwordField = new ViewPasswordField();
passwordField.setPromptText("Wallet password");
passwordField.setPromptText("Password");
password.bind(passwordField.textProperty());
HBox.setHgrow(passwordField, Priority.ALWAYS);
@@ -0,0 +1,168 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Control;
import javafx.scene.control.ToggleButton;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
public class FileKeystoreExportPane extends TitledDescriptionPane {
private final Keystore keystore;
private final KeystoreFileExport exporter;
private final boolean scannable;
private final boolean file;
public FileKeystoreExportPane(Keystore keystore, KeystoreFileExport exporter) {
super(exporter.getName(), "Keystore export", exporter.getKeystoreExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
this.keystore = keystore;
this.exporter = exporter;
this.scannable = exporter.isKeystoreExportScannable();
this.file = exporter.isKeystoreExportFile();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
}
@Override
protected Control createButton() {
if(scannable && file) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
showButton.setSelected(false);
exportQR();
});
ToggleButton fileButton = new ToggleButton("Export File...");
fileButton.setAlignment(Pos.CENTER_RIGHT);
fileButton.setOnAction(event -> {
fileButton.setSelected(false);
exportFile();
});
SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton;
} else if(scannable) {
Button showButton = new Button("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
exportQR();
});
return showButton;
} else {
Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
exportButton.setOnAction(event -> {
exportFile();
});
return exportButton;
}
}
private void exportQR() {
exportKeystore(null, keystore);
}
private void exportFile() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(keystore);
String fileName = keystore.getLabel();
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
exportKeystore(file, keystore);
}
}
private void exportKeystore(File file, Keystore exportKeystore) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
exporter.exportKeystore(exportKeystore, baos);
if(exporter.requiresSignature()) {
String message = baos.toString(StandardCharsets.UTF_8);
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getWalletModel().isCard()) {
TextAreaDialog dialog = new TextAreaDialog(message, false);
dialog.setTitle("Sign " + exporter.getName() + " Export");
dialog.getDialogPane().setHeaderText("The following text needs to be signed by the device.\nClick OK to continue.");
dialog.showAndWait();
Wallet wallet = new Wallet();
wallet.setScriptType(ScriptType.P2PKH);
wallet.getKeystores().add(keystore);
List<String> operationFingerprints = List.of(keystore.getKeyDerivation().getMasterFingerprint());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(operationFingerprints, wallet, message, keystore.getKeyDerivation());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
exporter.addSignature(keystore, optSignature.get(), baos);
}
} else if(keystore.getSource() == KeystoreSource.SW_SEED) {
String signature = keystore.getExtendedPrivateKey().getKey().signMessage(message, ScriptType.P2PKH);
exporter.addSignature(keystore, signature, baos);
} else {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Cannot sign export",
"Signing the " + exporter.getName() + " export with " + keystore.getWalletModel().toDisplayString() + " is not supported." +
"Proceed without signing?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.NO) {
throw new RuntimeException("Export aborted due to lack of device message signing support.");
}
}
}
if(file != null) {
try(OutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(baos.toByteArray());
EventManager.get().post(new KeystoreExportEvent(exportKeystore));
}
} else {
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof Bip129) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), baos.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
}
}
}
@@ -92,9 +92,11 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(wallet);
String fileName = wallet.getFullName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
String walletModel = exporter.getWalletModel().toDisplayString().toLowerCase(Locale.ROOT).replace(" ", "");
String postfix = walletModel.equals(extension) ? "" : "-" + walletModel;
String fileName = wallet.getFullName() + postfix;
if(exporter.exportsAllWallets()) {
fileName = wallet.getMasterName();
fileName = wallet.getMasterName() + postfix;
}
fileChooser.setInitialFileName(fileName + (extension == null || extension.isEmpty() ? "" : "." + extension));
@@ -148,7 +150,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 || exporter instanceof JadeMultisig) {
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig || exporter instanceof Bip129) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
@@ -18,7 +18,13 @@ public class FileWalletImportPane extends FileImportPane {
@Override
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
Wallet wallet = importer.importWallet(inputStream, password);
Wallet wallet;
if(getScannedWallets() != null && !getScannedWallets().isEmpty()) {
wallet = getScannedWallets().iterator().next();
} else {
wallet = importer.importWallet(inputStream, password);
}
if(wallet.getName() == null) {
wallet.setName(fileName);
}
@@ -0,0 +1,66 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.io.*;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Comparator;
import java.util.List;
public class KeystoreExportDialog extends Dialog<Keystore> {
public KeystoreExportDialog(Keystore keystore) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
stackPane.getChildren().add(anchorPane);
ScrollPane scrollPane = new ScrollPane();
scrollPane.setPrefHeight(200);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
List<KeystoreFileExport> exporters = List.of(new Bip129());
Accordion exportAccordion = new Accordion();
for(KeystoreFileExport exporter : exporters) {
if(!exporter.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileKeystoreExportPane exportPane = new FileKeystoreExportPane(keystore, exporter);
exportAccordion.getPanes().add(exportPane);
}
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(exportAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(280);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? keystore : null);
}
@Subscribe
public void keystoreExported(KeystoreExportEvent event) {
setResult(event.getKeystore());
}
}
@@ -1,21 +1,32 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.collections.FXCollections;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Point2D;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.chart.*;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.text.DateFormat;
@@ -27,16 +38,100 @@ import java.util.stream.Collectors;
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
public static final int MAX_PERIOD_HOURS = 2;
public static final int DEFAULT_MAX_PERIOD_HOURS = 2;
private static final double Y_VALUE_BREAK_MVB = 3.0;
private static final List<Integer> FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800);
private int maxPeriodHours = DEFAULT_MAX_PERIOD_HOURS;
private Tooltip tooltip;
private MempoolSizeFeeRatesChart expandedChart;
private final EventHandler<MouseEvent> expandedChartHandler = new EventHandler<>() {
@Override
public void handle(MouseEvent event) {
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
Stage stage = new Stage(StageStyle.UNDECORATED);
stage.setTitle("Mempool by vBytes");
stage.initOwner(MempoolSizeFeeRatesChart.this.getScene().getWindow());
stage.initModality(Modality.WINDOW_MODAL);
stage.setResizable(false);
StackPane scenePane = new StackPane();
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
if(Config.get().getTheme() == Theme.DARK) {
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
VBox vBox = new VBox(20);
vBox.setPadding(new Insets(20, 20, 20, 20));
expandedChart = new MempoolSizeFeeRatesChart();
expandedChart.initialize();
expandedChart.getStyleClass().add("vsizeChart");
expandedChart.update(AppServices.getMempoolHistogram());
expandedChart.setLegendVisible(false);
expandedChart.setAnimated(false);
expandedChart.setPrefWidth(700);
HBox buttonBox = new HBox();
buttonBox.setAlignment(Pos.CENTER);
ToggleGroup periodGroup = new ToggleGroup();
ToggleButton period2 = new ToggleButton("2H");
ToggleButton period24 = new ToggleButton("24H");
SegmentedButton periodButtons = new SegmentedButton(period2, period24);
periodButtons.setToggleGroup(periodGroup);
periodGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
expandedChart.maxPeriodHours = (newValue == period2 ? 2 : 24);
expandedChart.update(AppServices.getMempoolHistogram());
});
Optional<Date> optEarliest = AppServices.getMempoolHistogram().keySet().stream().findFirst();
period24.setDisable(optEarliest.isEmpty() || optEarliest.get().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().isAfter(LocalDateTime.now().minusHours(2)));
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
Button button = new Button("Close");
button.setOnAction(e -> {
stage.close();
});
buttonBox.getChildren().addAll(periodButtons, region, button);
vBox.getChildren().addAll(expandedChart, buttonBox);
scenePane.getChildren().add(vBox);
Scene scene = new Scene(scenePane);
AppServices.onEscapePressed(scene, stage::close);
AppServices.setStageIcon(stage);
stage.setScene(scene);
stage.setOnShowing(e -> {
AppServices.moveToActiveWindowScreen(stage, 800, 460);
});
stage.setOnHidden(e -> {
expandedChart = null;
});
stage.show();
}
}
};
public MempoolSizeFeeRatesChart() {
super(new CategoryAxis(), new NumberAxis());
}
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis);
setOnMouseClicked(expandedChartHandler);
}
public void initialize() {
getStyleClass().add("vsizeChart");
setCreateSymbols(false);
setCursor(Cursor.CROSSHAIR);
setVerticalGridLinesVisible(false);
@@ -78,17 +173,18 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
}
});
long previousFeeRate = 0;
for(Long feeRate : AppServices.FEE_RATES_RANGE) {
for(int i = 0; i < FEE_RATES_INTERVALS.size(); i++) {
int feeRate = FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : FEE_RATES_INTERVALS.get(i+1));
XYChart.Series<String, Number> series = new XYChart.Series<>();
series.setName(feeRate + "+ sats/vB");
series.setName(feeRate + "-" + (nextFeeRate == Integer.MAX_VALUE ? 900 : nextFeeRate));
long seriesTotalVSize = 0;
for(Date date : periodRateSizes.keySet()) {
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
long totalVSize = 0;
for(MempoolRateSize rateSize : rateSizes) {
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) {
if(rateSize.getFee() >= feeRate && rateSize.getFee() < nextFeeRate) {
totalVSize += rateSize.getVSize();
}
}
@@ -100,8 +196,19 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
if(seriesTotalVSize > 0) {
getData().add(series);
}
}
previousFeeRate = feeRate;
for(int i = 0; i < getData().size(); i++) {
Series<String, Number> series = getData().get(i);
Set<Node> nodes = lookupAll(".series" + i);
for(Node node : nodes) {
if(node.getStyleClass().contains("chart-series-area-line")) {
node.setStyle("-fx-stroke: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.2;");
} else {
node.setStyle("-fx-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.5;");
}
node.getStyleClass().remove("default-color" + i);
}
}
final double maxMvB = getMaxMvB(getData());
@@ -131,6 +238,10 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
numberAxis.setTickLabelsVisible(false);
numberAxis.setOpacity(0);
}
if(expandedChart != null) {
expandedChart.update(mempoolRateSizes);
}
}
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
@@ -138,7 +249,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
return mempoolRateSizes;
}
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
LocalDateTime period = LocalDateTime.now().minusHours(maxPeriodHours);
return mempoolRateSizes.entrySet().stream().filter(entry -> {
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
return dateTime.isAfter(period);
@@ -200,11 +311,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
double mvb = kvb / 1000;
if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) {
String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB");
Label label = new Label(series.getName() + ": " + amount);
Label label = new Label(series.getName() + " sats/vB: " + amount);
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
if(i < 8) {
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
}
circle.setStyle("-fx-text-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.7;");
label.setGraphic(circle);
getChildren().add(label);
}
@@ -0,0 +1,415 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.FileChooser;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.stream.Collectors;
public class MnemonicGridDialog extends Dialog<List<String>> {
private final SpreadsheetView spreadsheetView;
private final int GRID_COLUMN_COUNT = 16;
private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false);
private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false);
private final ObservableList<TablePosition> selectedCells = FXCollections.observableArrayList();
public MnemonicGridDialog() {
DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
setTitle("Border Wallets Grid");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm());
dialogPane.setHeaderText("Load a Border Wallets PDF, or generate a grid from a BIP39 seed.\nThen select 11 or 23 words in a pattern on the grid.\nThe order of selection is important!");
javafx.scene.image.Image image = new Image("/image/border-wallets.png");
dialogPane.setGraphic(new ImageView(image));
String[][] emptyWordGrid = new String[128][GRID_COLUMN_COUNT];
Grid grid = getGrid(emptyWordGrid);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.setId("grid");
spreadsheetView.setEditable(false);
spreadsheetView.setFixingColumnsAllowed(false);
spreadsheetView.setFixingRowsAllowed(false);
spreadsheetView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() {
@Override
public void onChanged(Change<? extends TablePosition> c) {
while(c.next()) {
if(c.wasAdded()) {
for(TablePosition<?, ?> pos : c.getAddedSubList()) {
if(selectedCells.contains(pos)) {
selectedCells.remove(pos);
} else {
selectedCells.add(pos);
}
}
}
}
int numWords = selectedCells.size();
wordsSelectedProperty.set(numWords == 11 || numWords == 23);
}
});
selectedCells.addListener((ListChangeListener<? super TablePosition>) c -> {
while(c.next()) {
if(c.wasRemoved()) {
for(TablePosition<?,?> pos : c.getRemoved()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().remove("selection");
cell.setGraphic(null);
}
}
if(c.wasAdded()) {
for(TablePosition<?,?> pos : c.getAddedSubList()) {
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
cell.getStyleClass().add("selection");
}
}
for(int i = 0; i < selectedCells.size(); i++) {
Text index = new Text(Integer.toString(i+1));
index.setFont(Font.font(8));
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(selectedCells.get(i).getRow()).get(selectedCells.get(i).getColumn());
cell.setGraphic(index);
}
}
});
StackPane stackPane = new StackPane();
stackPane.getChildren().add(spreadsheetView);
dialogPane.setContent(stackPane);
stackPane.widthProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
for(SpreadsheetColumn column : spreadsheetView.getColumns()) {
column.setPrefWidth((newValue.doubleValue() - spreadsheetView.getRowHeaderWidth() - 3) / 17);
}
}
});
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate Grid...", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().add(generateButtonType);
final ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear Selection", ButtonBar.ButtonData.OTHER);
dialogPane.getButtonTypes().add(clearButtonType);
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
okButton.disableProperty().bind(Bindings.not(Bindings.and(initializedProperty, wordsSelectedProperty)));
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null;
});
dialogPane.setPrefWidth(952);
dialogPane.setPrefHeight(500);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
private Grid getGrid(String[][] wordGrid) {
int rowCount = wordGrid.length;
int columnCount = wordGrid[0].length;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
grid.getColumnHeaders().setAll("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P");
for(int i = 0; i < rowCount; i++) {
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
for(int j = 0; j < columnCount; j++) {
list.add(createCell(i, j, wordGrid[i][j]));
}
rows.add(list);
grid.getRowHeaders().add(String.format("%03d", i + 1));
}
grid.setRows(rows);
return grid;
}
private SpreadsheetCell createCell(int row, int column, String word) {
return SpreadsheetCellType.STRING.createCell(row, column, 1, 1, word == null ? "" : word);
}
private List<String> getSelectedWords() {
List<String> abbreviations = selectedCells.stream()
.map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList());
List<String> words = new ArrayList<>();
for(String abbreviation : abbreviations) {
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
words.add(word);
break;
}
}
try {
int index = Integer.parseInt(abbreviation);
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
} catch(NumberFormatException e) {
//ignore
}
}
if(words.size() != abbreviations.size()) {
abbreviations.removeIf(abbr -> words.stream().anyMatch(w -> w.startsWith(abbr)));
throw new IllegalStateException("Could not find words for abbreviations: " + abbreviations);
}
return words;
}
public List<String> shuffle(List<String> mnemonic) {
String mnemonicString = String.join(" ", mnemonic);
List<String> words = new ArrayList<>(Bip39MnemonicCode.INSTANCE.getWordList());
UltraHighEntropyPrng uhePrng = new UltraHighEntropyPrng();
uhePrng.initState();
uhePrng.hashString(mnemonicString);
for(int i = words.size() - 1; i > 0; i--) {
int j = (int)uhePrng.random(i + 1);
String tmp = words.get(i);
words.set(i, words.get(j));
words.set(j, tmp);
}
return words;
}
private String[][] toGrid(List<String> words) {
String[][] grid = new String[words.size()/GRID_COLUMN_COUNT][GRID_COLUMN_COUNT];
int row = 0;
int col = 0;
for(String word : words) {
String abbr = word.length() < 4 ? word : word.substring(0, 4);
grid[row][col] = abbr;
col++;
if(col >= GRID_COLUMN_COUNT) {
col = 0;
row++;
}
}
return grid;
}
private class MnemonicGridDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open PDF");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("PDF", "*.pdf")
);
AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450);
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
String[][] wordGrid = PdfUtils.getWordGrid(inputStream);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
} catch(Exception e) {
AppServices.showErrorDialog("Cannot load PDF", e.getMessage());
}
}
});
button = loadButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
SeedEntryDialog seedEntryDialog = new SeedEntryDialog("Border Wallets Entropy Grid Recovery Seed", 12);
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
if(optWords.isPresent()) {
List<String> mnemonicWords = optWords.get();
List<String> shuffledWordList = shuffle(mnemonicWords);
String[][] wordGrid = toGrid(shuffledWordList);
spreadsheetView.setGrid(getGrid(wordGrid));
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
initializedProperty.set(true);
if(seedEntryDialog.isGenerated()) {
PdfUtils.saveWordGrid(wordGrid, mnemonicWords);
}
}
});
button = generateButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OTHER) {
Button clearButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(clearButton, buttonData);
clearButton.setOnAction(event -> {
selectedCells.clear();
spreadsheetView.getSelectionModel().clearSelection();
});
button = clearButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class UltraHighEntropyPrng {
private final int order;
private double carry;
private int phase;
private final double[] intermediates;
private int i, j, k; // general purpose locals
public UltraHighEntropyPrng() {
order = 48; // set the 'order' number of ENTROPY-holding 32-bit values
carry = 1; // init the 'carry' used by the multiply-with-carry (MWC) algorithm
phase = order; // init the 'phase' (max-1) of the intermediate variable pointer
intermediates = new double[order]; // declare our intermediate variables array
for(i = 0; i < order; i++) {
// Used to simulate javascript's Math.random
Random random = new Random();
intermediates[i] = mash(random.nextInt(Integer.MAX_VALUE)); // fill the array with initial mash hash values
}
}
public double random(int range) {
return Math.floor(range * (rawPrng() + ((long)(rawPrng() * 0x200000L)) * 1.1102230246251565e-16)); // 2^-53
}
private String randomString(int count) {
StringBuilder stringBuilder = new StringBuilder();
for(i = 0; i < count; i++) {
char newChar = (char) (33 + random(94));
stringBuilder.append(newChar);
}
return stringBuilder.toString();
}
private double rawPrng() {
if(++phase >= order) {
phase = 0;
}
double t = 1768863 * intermediates[phase] + carry * 2.3283064365386963e-10; // 2^-32
long temp = (long)t;
return intermediates[phase] = t - (carry = temp);
}
private void hash(String args) {
for(i = 0; i < args.length(); i++) {
for(j = 0; j < order; j++) {
intermediates[j] -= mash(args.charAt(i));
if(intermediates[j] < 0) {
intermediates[j] = intermediates[j] + 1;
}
}
}
}
public void hashString(String input) {
mash(input); // use the string to evolve the 'mash' state
char[] inputAry = input.toCharArray();
for(i = 0; i < inputAry.length; i++) // scan through the characters in our string
{
k = inputAry[i]; // get the character code at the location
for(j = 0; j < order; j++) // "mash" it into the UHEPRNG state
{
intermediates[j] -= mash(k);
if(intermediates[j] < 0) {
intermediates[j] += 1;
}
}
}
}
public void initState() {
mash(null);
for(i = 0; i < order; i++) {
intermediates[i] = mash(' '); // fill the array with initial mash hash values
}
carry = 1; // init our multiply-with-carry carry
phase = order; // init our phase
}
double n = Integer.toUnsignedLong(0xefc8249d);
private double mash(Object data) {
if(data != null) {
String strData = data.toString();
for(int i = 0; i < strData.length(); i++) {
n += strData.charAt(i);
double h = 0.02519603282416938 * n;
n = Integer.toUnsignedLong((int)h);
h -= n;
h *= n;
n = Integer.toUnsignedLong((int)h);
h -= n;
n += h * 0x100000000L; // 2^32
}
return ((long)n) * 2.3283064365386963e-10; // 2^-32
} else {
n = Integer.toUnsignedLong(0xefc8249d);
}
return n;
}
}
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.SimpleListProperty;
@@ -24,7 +23,8 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
showWordList(keystore.getSeed());
}
protected Node getMnemonicWordsEntry(int numWords) {
@Override
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -0,0 +1,95 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node;
import javafx.scene.control.Button;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.List;
public class MnemonicKeystoreEntryPane extends MnemonicKeystorePane {
private final BooleanProperty validProperty = new SimpleBooleanProperty(false);
private boolean generated;
public MnemonicKeystoreEntryPane(String name, int numWords) {
super(name, "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
defaultWordSizeProperty.set(numWords);
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords, false, true));
setExpanded(true);
}
@Override
protected List<Node> createRightButtons() {
Button button = new Button("Next");
button.setVisible(false);
return List.of(button);
}
@Override
protected void onWordChange(boolean empty, boolean validWords, boolean validChecksum) {
if(!empty && validWords) {
try {
Bip39MnemonicCode.INSTANCE.check(wordEntriesProperty.get());
validChecksum = true;
} catch(MnemonicException e) {
invalidLabel.setText("Invalid checksum");
invalidLabel.setTooltip(null);
}
}
validProperty.set(validChecksum);
validLabel.setVisible(validChecksum);
invalidLabel.setVisible(!validChecksum && !empty);
}
public void generateNew() {
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
SecureRandom secureRandom;
try {
secureRandom = SecureRandom.getInstanceStrong();
} catch(NoSuchAlgorithmException e) {
secureRandom = new SecureRandom();
}
DeterministicSeed deterministicSeed = new DeterministicSeed(secureRandom, entropyLength, "");
displayMnemonicCode(deterministicSeed);
generated = true;
}
private void displayMnemonicCode(DeterministicSeed deterministicSeed) {
setDescription("Write down these words");
showHideLink.setVisible(false);
for (int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(deterministicSeed.getMnemonicCode().get(i));
wordEntry.getEditor().setEditable(false);
}
}
public boolean isValid() {
return validProperty.get();
}
public BooleanProperty validProperty() {
return validProperty;
}
public boolean isGenerated() {
return generated;
}
}
@@ -223,7 +223,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private void confirmBackup() {
setDescription("Confirm backup by re-entering words");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size()));
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), true, false));
setExpanded(true);
backButton.setVisible(true);
generateButton.setVisible(false);
@@ -12,6 +12,8 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
@@ -20,8 +22,8 @@ import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.*;
import javafx.util.Callback;
import javafx.util.Duration;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -44,7 +46,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected Label invalidLabel;
protected SimpleListProperty<String> wordEntriesProperty;
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty("");
protected IntegerProperty defaultWordSizeProperty;
public MnemonicKeystorePane(String title, String description, String content, String imageUrl) {
@@ -79,6 +81,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.getItems().add(item);
}
enterMnemonicButton.getItems().add(new SeparatorMenuItem());
MenuItem gridItem = new MenuItem("Border Wallets Grid...");
gridItem.setOnAction(event -> {
showGrid();
});
enterMnemonicButton.getItems().add(gridItem);
MenuItem scanItem = new MenuItem("Scan QR...");
scanItem.setOnAction(event -> {
scanQR();
@@ -87,6 +95,43 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
}
protected void showGrid() {
MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog();
Optional<List<String>> optWords = mnemonicGridDialog.showAndWait();
if(optWords.isPresent()) {
List<String> words = optWords.get();
defaultWordSizeProperty.set(words.size() + 1);
setContent(getMnemonicWordsEntry(words.size() + 1, true, true));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
if(i < words.size()) {
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
} else {
ScheduledService<Void> service = new ScheduledService<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
return null;
}
};
}
};
service.setDelay(Duration.millis(500));
service.setOnSucceeded(event1 -> {
service.cancel();
Platform.runLater(() -> wordEntry.getEditor().requestFocus());
});
service.start();
}
}
}
}
protected void scanQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
@@ -106,7 +151,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setContent(getMnemonicWordsEntry(words.size(), true, true));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
@@ -119,11 +164,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected void enterMnemonic(int numWords) {
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
setContent(getMnemonicWordsEntry(numWords));
setContent(getMnemonicWordsEntry(numWords, true, true));
setExpanded(true);
}
protected Node getMnemonicWordsEntry(int numWords) {
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -152,10 +197,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
vBox.getChildren().add(wordsPane);
PassphraseEntry passphraseEntry = new PassphraseEntry();
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
if(showPassphrase) {
PassphraseEntry passphraseEntry = new PassphraseEntry(editPassphrase);
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
}
AnchorPane buttonPane = new AnchorPane();
buttonPane.setPadding(new Insets(0, 26, 0, 10));
@@ -250,9 +297,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) {
String[] words = clipboard.getString().split("[\\n\\s]");
WordEntry entry = WordEntry.this;
for(String word : words) {
for(int i = 0; i < words.length; i++) {
String word = words[i];
if(entry.nextField != null) {
entry.nextField.requestFocus();
if(i == words.length - 2 && isValid(word)) {
label.requestFocus();
} else {
entry.nextField.requestFocus();
}
}
entry.wordField.setText(word);
@@ -282,7 +334,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
wordField.setTextFormatter(formatter);
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList, wordNumber, wordEntryList));
autoCompletionBinding.setDelay(50);
autoCompletionBinding.setOnAutoCompleted(event -> {
if(nextField != null) {
@@ -290,11 +342,20 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
}
});
//Show autocomplete for the last word on focus if empty
boolean lastWord = wordNumber == wordEntryList.size() - 1;
if(lastWord) {
wordField.focusedProperty().addListener((observable, oldValue, focused) -> {
if(focused && wordField.getText().isEmpty()) {
autoCompletionBinding.setUserInput("");
}
});
}
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(wordField, Validator.combine(
Validator.createEmptyValidator("Word is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", (newValue.length() > 0 || !lastWord) && !wordList.contains(newValue))
));
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
@@ -323,13 +384,30 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> {
private final List<String> wordList;
private final int wordNumber;
private final ObservableList<String> wordEntryList;
public WordlistSuggestionProvider(List<String> wordList) {
public WordlistSuggestionProvider(List<String> wordList, int wordNumber, ObservableList<String> wordEntryList) {
this.wordList = wordList;
this.wordNumber = wordNumber;
this.wordEntryList = wordEntryList;
}
@Override
public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) {
if(wordNumber == wordEntryList.size() - 1 && allPreviousWordsValid()) {
try {
List<String> possibleLastWords = Bip39MnemonicCode.INSTANCE.getPossibleLastWords(wordEntryList.subList(0, wordEntryList.size() - 1));
if(!request.getUserText().isEmpty()) {
possibleLastWords.removeIf(s -> !s.startsWith(request.getUserText()));
}
return possibleLastWords;
} catch(Exception e) {
log.warn("Cannot determine possible last words", e);
}
}
List<String> suggestions = new ArrayList<>();
if(!request.getUserText().isEmpty()) {
for(String word : wordList) {
@@ -341,26 +419,63 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
return suggestions;
}
private boolean allPreviousWordsValid() {
for(int i = 0; i < wordEntryList.size() - 1; i++) {
if(!WordEntry.isValid(wordEntryList.get(i))) {
return false;
}
}
return true;
}
}
protected class PassphraseEntry extends HBox {
private final CustomTextField passphraseField;
private final TextField passphraseField;
public PassphraseEntry() {
public PassphraseEntry(boolean editable) {
super();
setAlignment(Pos.CENTER_LEFT);
setSpacing(10);
Label usePassphraseLabel = new Label("Use passphrase?");
usePassphraseLabel.managedProperty().bind(usePassphraseLabel.visibleProperty());
CheckBox usePassphraseCheckbox = new CheckBox(" ");
usePassphraseCheckbox.setDisable(!editable);
usePassphraseCheckbox.managedProperty().bind(usePassphraseCheckbox.visibleProperty());
usePassphraseLabel.visibleProperty().bind(usePassphraseCheckbox.visibleProperty());
usePassphraseCheckbox.setVisible(passphraseProperty.isEmpty().get());
usePassphraseCheckbox.selectedProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Add a passphrase?", "A passphrase is not a password! " +
"Any variation entered in future loads a valid wallet, but with different addresses.\n\n" +
"This feature provides optional added security for advanced users only. Are you sure?", ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.YES) {
usePassphraseCheckbox.setVisible(false);
} else {
usePassphraseCheckbox.setSelected(false);
}
}
});
Label passphraseLabel = new Label("Passphrase:");
passphraseField = (CustomTextField) TextFields.createClearableTextField();
passphraseLabel.managedProperty().bind(passphraseLabel.visibleProperty());
passphraseField = new TextField();
passphraseField.setPromptText(passphraseProperty.isEmpty().get() ? "Leave blank for none" : "");
passphraseField.setText(passphraseProperty.get());
passphraseField.setDisable(!editable);
passphraseField.managedProperty().bind(passphraseField.visibleProperty());
passphraseLabel.visibleProperty().bind(passphraseField.visibleProperty());
passphraseField.visibleProperty().bind(usePassphraseCheckbox.visibleProperty().not());
passphraseProperty.bind(passphraseField.textProperty());
passphraseField.setPromptText("Leave blank for none");
HelpLabel helpLabel = new HelpLabel();
helpLabel.setPrefHeight(28);
helpLabel.setStyle("-fx-padding: 0 0 0 0");
helpLabel.setHelpText("A passphrase provides optional added security - it is not stored so it must be remembered!");
helpLabel.setHelpText("Advanced feature: a passphrase provides optional added security, but it is not stored so it must be remembered!");
getChildren().addAll(passphraseLabel, passphraseField, helpLabel);
getChildren().addAll(usePassphraseLabel, usePassphraseCheckbox, passphraseLabel, passphraseField, helpLabel);
}
public TextField getEditor() {
@@ -10,9 +10,11 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreMnemonicImport;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -135,7 +137,10 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
EventManager.get().post(new WalletImportEvent(optWallet.get()));
} else {
discoverButton.setDisable(false);
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found", "Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optButtonType = AppServices.showErrorDialog("No existing wallet found",
Config.get().getServerType() == ServerType.BITCOIN_CORE ? "The configured server type is Bitcoin Core, which does not support wallet discovery.\n\n" +
"You can however import this wallet and scan the blockchain by supplying a start date. Do you want to import this wallet?" :
"Could not find a wallet with existing transactions using this mnemonic. Import this wallet anyway?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
setContent(getScriptTypeEntry());
setExpanded(true);
@@ -183,7 +188,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
scriptTypeComboBox.setMaxWidth(170);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
helpLabel.setHelpText("Native Segwit is usually the best choice for new wallets.\nTaproot is a new type useful for specific needs.\nNested Segwit and Legacy are useful for recovering older wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel);
Region region = new Region();
@@ -378,11 +378,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
PSBT psbt = new PSBT(transaction);
//Set witness UTXOs on PSBT inputs first - they are all required when hashing for a Taproot signature
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
}
for(int i = 0; i < txOutputs.size(); i++) {
TransactionOutput utxoOutput = txOutputs.get(i);
TransactionInput txInput = transaction.getInputs().get(i);
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
psbtInput.setWitnessUtxo(utxoOutput);
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
@@ -16,6 +16,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -24,6 +25,8 @@ import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.URDecoder;
import com.sparrowwallet.hummingbird.registry.pathcomponent.IndexPathComponent;
import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
@@ -53,6 +56,7 @@ import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@SuppressWarnings("deprecation")
@@ -206,6 +210,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
URDecoder.Result urResult = decoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result));
} else {
result = new Result(new URException(urResult.error));
}
@@ -259,7 +264,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
DeterministicSeed seed;
try {
extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey);
result = new Result(extendedKey, null);
return;
} catch(Exception e) {
//Ignore, not a valid xpub
@@ -350,6 +355,17 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Ignore, not parseable as a CompactSeedQR
}
try {
List<String> words = Arrays.asList(qrtext.split(" "));
if(words.size() == 12 || words.size() == 15 || words.size() == 18 || words.size() == 21 || words.size() == 24) {
Bip39MnemonicCode.INSTANCE.check(words);
result = new Result(new DeterministicSeed(words, null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39));
return;
}
} catch(Exception e) {
//Ignore, not parseable as BIP39 seed words
}
result = new Result(qrtext);
}
}
@@ -408,7 +424,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
return new Result(extendedKey);
return new Result(extendedKey, cryptoHDKey.getName());
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
@@ -466,7 +482,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(cryptoHDKey.getOrigin() != null) {
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened());
if(lastComponent instanceof IndexPathComponent indexPathComponent) {
lastChild = new ChildNumber(indexPathComponent.getIndex(), indexPathComponent.isHardened());
}
depth = cryptoHDKey.getOrigin().getComponents().size();
}
if(cryptoHDKey.getParentFingerprint() != null) {
@@ -542,7 +560,13 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
if(cryptoKeypath != null) {
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), cryptoKeypath.getPath());
if(!cryptoKeypath.getComponents().stream().allMatch(pathComponent -> pathComponent instanceof IndexPathComponent)) {
throw new IllegalArgumentException("Only indexed derivation path components are supported");
}
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), KeyDerivation.writePath(path));
}
return null;
@@ -656,6 +680,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final PSBT psbt;
public final BitcoinURI uri;
public final ExtendedKey extendedKey;
public final String extendedKeyName;
public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets;
public final DeterministicSeed seed;
@@ -667,6 +692,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -679,6 +705,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = psbt;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -691,6 +718,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = uri;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -703,6 +731,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -710,11 +739,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.exception = null;
}
public Result(ExtendedKey extendedKey) {
public Result(ExtendedKey extendedKey, String name) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = extendedKey;
this.extendedKeyName = name;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -727,6 +757,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = outputDescriptor;
this.wallets = null;
this.seed = null;
@@ -739,6 +770,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = wallets;
this.seed = null;
@@ -751,6 +783,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = seed;
@@ -763,6 +796,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -775,6 +809,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.extendedKeyName = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
@@ -58,6 +58,8 @@ public class ScriptArea extends CodeArea {
append("<signature" + signatureCount++ + ">", "script-signature");
} else if(chunk.isPubKey()) {
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
} else if(chunk.isString()) {
append(chunk.toString(), "script-other");
} else if(chunk.isScript()) {
Script nestedScript = chunk.getScript();
if (nestedScript.equals(redeemScript)) {
@@ -0,0 +1,91 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.List;
public class SeedEntryDialog extends Dialog<List<String>> {
private final MnemonicKeystoreEntryPane keystorePane;
public SeedEntryDialog(String name, int numWords) {
final DialogPane dialogPane = new MnemonicGridDialogPane();
setDialogPane(dialogPane);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
int lines = numWords / 3;
int height = lines * 40;
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.getStyleClass().add("edge-to-edge");
scrollPane.setPrefHeight(104 + height);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
Accordion keystoreAccordion = new Accordion();
scrollPane.setContent(keystoreAccordion);
keystorePane = new MnemonicKeystoreEntryPane(name, numWords);
keystorePane.setAnimated(false);
keystoreAccordion.getPanes().add(keystorePane);
stackPane.getChildren().addAll(anchorPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
okButton.disableProperty().bind(keystorePane.validProperty().not());
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate New", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(generateButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? keystorePane.wordEntriesProperty.get() : null;
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(180 + height);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
}
private class MnemonicGridDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button generateButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(generateButton, buttonData);
generateButton.setOnAction(event -> {
keystorePane.generateNew();
});
button = generateButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
}
public boolean isGenerated() {
return keystorePane.isGenerated();
}
}
@@ -11,10 +11,13 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.Optional;
public class TextAreaDialog extends Dialog<String> {
@@ -22,6 +25,8 @@ public class TextAreaDialog extends Dialog<String> {
private final TextArea textArea;
private final String defaultValue;
private final String fileName;
private final byte[] fileBytes;
public TextAreaDialog() {
this("");
@@ -31,7 +36,11 @@ public class TextAreaDialog extends Dialog<String> {
this(defaultValue, true);
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
public TextAreaDialog(String defaultValue, boolean editable) {
this(defaultValue, editable, null, null);
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable, @NamedArg("fileName") String fileName, @NamedArg("fileBytes") byte[] fileBytes) {
final DialogPane dialogPane = new TextAreaDialogPane();
setDialogPane(dialogPane);
@@ -48,6 +57,8 @@ public class TextAreaDialog extends Dialog<String> {
HBox.setHgrow(this.textArea, Priority.ALWAYS);
this.defaultValue = defaultValue;
this.fileName = fileName;
this.fileBytes = fileBytes;
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
@@ -60,6 +71,9 @@ public class TextAreaDialog extends Dialog<String> {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(scanButtonType);
} else if(fileBytes != null) {
final ButtonType saveButtonType = new javafx.scene.control.ButtonType("Save File...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(saveButtonType);
}
Platform.runLater(textArea::requestFocus);
@@ -86,7 +100,7 @@ public class TextAreaDialog extends Dialog<String> {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Scan QR")) {
Button scanButton = new Button(buttonType.getText());
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
@@ -118,6 +132,38 @@ public class TextAreaDialog extends Dialog<String> {
});
button = scanButton;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Save File...")) {
Button saveButton = new Button(buttonType.getText());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(saveButton, buttonData);
saveButton.setOnAction(event -> {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save File");
if(fileName != null) {
fileChooser.setInitialFileName(fileName);
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try {
try(OutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(fileBytes);
outputStream.flush();
}
} catch(IOException e) {
log.error("Error saving file", e);
AppServices.showErrorDialog("Error saving file", "Cannot write to " + file.getAbsolutePath());
}
}
});
button = saveButton;
} else {
button = super.createButton(buttonType);
}
@@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TextfieldDialog extends Dialog<String> {
private static final Logger log = LoggerFactory.getLogger(TextAreaDialog.class);
private final TextField textField;
private final String defaultValue;
public TextfieldDialog() {
this("");
}
public TextfieldDialog(String defaultValue) {
this(defaultValue, true);
}
public TextfieldDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
final DialogPane dialogPane = getDialogPane();
setDialogPane(dialogPane);
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
HBox hbox = new HBox();
this.textField = new TextField(defaultValue);
this.textField.setMaxWidth(Double.MAX_VALUE);
this.textField.setEditable(editable);
hbox.getChildren().add(textField);
HBox.setHgrow(this.textField, Priority.ALWAYS);
this.defaultValue = defaultValue;
dialogPane.setContent(hbox);
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.getStyleClass().add("text-input-dialog");
dialogPane.getButtonTypes().add(ButtonType.OK);
if(editable) {
dialogPane.getButtonTypes().add(ButtonType.CANCEL);
}
Platform.runLater(textField::requestFocus);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? textField.getText() : null;
});
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(230);
AppServices.moveToActiveWindowScreen(this);
}
public final TextField getEditor() {
return textField;
}
public final String getDefaultValue() {
return defaultValue;
}
}
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.geometry.Insets;
import javafx.scene.Group;
import javafx.scene.Node;
@@ -33,15 +34,15 @@ public class TorStatusLabel extends Label {
}
public void update() {
boolean proxyInUse = AppServices.isUsingProxy();
boolean internal = AppServices.isTorRunning();
if(!proxyInUse || internal) {
if(!Config.get().isUseProxy()) {
torConnectionTest.cancel();
if(internal) {
if(AppServices.isTorRunning()) {
setTooltip(new Tooltip("Internal Tor proxy enabled"));
}
} else if(!torConnectionTest.isRunning()) {
if(torConnectionTest.getState() == Worker.State.CANCELLED || torConnectionTest.getState() == Worker.State.FAILED) {
torConnectionTest.reset();
}
torConnectionTest.setPeriod(Duration.seconds(20.0));
torConnectionTest.setBackoffStrategy(null);
torConnectionTest.setOnSucceeded(workerStateEvent -> {
@@ -535,7 +535,8 @@ public class TransactionDiagram extends GridPane {
}
private String getSatsValue(long amount) {
return String.format(Locale.ENGLISH, "%,d", amount);
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return format.formatSatsValue(amount);
}
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
@@ -44,7 +44,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
if(wallet.getPolicyType() == PolicyType.SINGLE) {
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 JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels());
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -59,7 +59,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
}
}
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()));
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()));
if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms));
}
@@ -34,6 +34,14 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
}
public WalletNameDialog(String initialName) {
this(initialName, false);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions) {
this(initialName, hasExistingTransactions, null);
}
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
@@ -91,6 +99,12 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
if(requestBirthDate) {
content.getChildren().add(existingBox);
if(hasExistingTransactions) {
existingCheck.setSelected(true);
}
if(startDate != null) {
existingPicker.setValue(startDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
}
}
dialogPane.setContent(content);
@@ -14,16 +14,21 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private WebcamResolution resolution;
private WebcamDevice device;
private final WebcamListener listener;
@@ -137,6 +142,9 @@ public class WebcamService extends ScheduledService<Image> {
if(result == null) {
result = readQR(croppedImage);
}
if(result == null) {
result = readQR(invert(croppedImage));
}
if(result != null) {
resultProperty.set(result);
@@ -175,6 +183,29 @@ public class WebcamService extends ScheduledService<Image> {
return new CroppedDimension(x, y, squareSize);
}
public BufferedImage invert(BufferedImage inImg) {
try {
int width = inImg.getWidth();
int height = inImg.getHeight();
BufferedImage outImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
WritableRaster outRaster = outImg.getRaster();
WritableRaster inRaster = inImg.getRaster();
for(int y = 0; y < height; y++) {
for(int x = 0; x < width; x++) {
for(int i = 0; i < outRaster.getNumBands(); i++) {
outRaster.setSample(x, y, i, 255 - inRaster.getSample(x, y, i));
}
}
}
return outImg;
} catch(Exception e) {
log.warn("Error inverting image", e);
return inImg;
}
}
public Result getResult() {
return resultProperty.get();
}
@@ -0,0 +1,21 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class ExistingWalletImportedEvent {
private final String existingWalletId;
private final Wallet importedWallet;
public ExistingWalletImportedEvent(String existingWalletId, Wallet importedWallet) {
this.existingWalletId = existingWalletId;
this.importedWallet = importedWallet;
}
public String getExistingWalletId() {
return existingWalletId;
}
public Wallet getImportedWallet() {
return importedWallet;
}
}
@@ -5,20 +5,15 @@ import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Map;
import java.util.Set;
public class FeeRatesUpdatedEvent {
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
private final Set<MempoolRateSize> mempoolRateSizes;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
super(mempoolRateSizes);
this.targetBlockFeeRates = targetBlockFeeRates;
this.mempoolRateSizes = mempoolRateSizes;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
public class KeystoreExportEvent {
private final Keystore keystore;
public KeystoreExportEvent(Keystore keystore) {
this.keystore = keystore;
}
public Keystore getKeystore() {
return keystore;
}
}
@@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.event;
/**
* The event is posted when the first set of mempool entries (txid and vsizes) have been retrieved from the node.
* Cormorant only.
*/
public class MempoolEntriesInitializedEvent {
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.Set;
public class MempoolRateSizesUpdatedEvent {
private final Set<MempoolRateSize> mempoolRateSizes;
public MempoolRateSizesUpdatedEvent(Set<MempoolRateSize> mempoolRateSizes) {
this.mempoolRateSizes = mempoolRateSizes;
}
public Set<MempoolRateSize> getMempoolRateSizes() {
return mempoolRateSizes;
}
}
@@ -0,0 +1,24 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.io.Storage;
public class NewChildWalletSavedEvent {
private final Storage storage;
private final Wallet masterWallet;
private final Wallet childWallet;
public NewChildWalletSavedEvent(Storage storage, Wallet masterWallet, Wallet childWallet) {
this.storage = storage;
this.masterWallet = masterWallet;
this.childWallet = childWallet;
}
public String getMasterWalletId() {
return storage.getWalletId(masterWallet);
}
public Wallet getChildWallet() {
return childWallet;
}
}
@@ -1,13 +1,10 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.TorServerAlreadyBoundException;
public class TorFailedStatusEvent extends TorStatusEvent {
private final Throwable exception;
public TorFailedStatusEvent(Throwable exception) {
super("Tor failed to start: " + (exception instanceof TorServerAlreadyBoundException ? exception.getCause().getMessage() + " Is a Tor proxy already running?" :
(exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage())));
super("Tor failed to start: " + (exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage()));
this.exception = exception;
}
@@ -1,17 +1,41 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
/**
* This event is posted if the wallet's gap limit has changed, and triggers a history fetch for the new nodes.
*
*/
public class WalletGapLimitChangedEvent extends WalletChangedEvent {
public WalletGapLimitChangedEvent(Wallet wallet) {
private final String walletId;
private final int previousGapLimit;
public WalletGapLimitChangedEvent(String walletId, Wallet wallet, int previousGapLimit) {
super(wallet);
this.walletId = walletId;
this.previousGapLimit = previousGapLimit;
}
public String getWalletId() {
return walletId;
}
public int getGapLimit() {
return getWallet().getGapLimit();
}
public int getPreviousGapLimit() {
return previousGapLimit;
}
public int getPreviousLookAheadIndex(WalletNode node) {
int lookAheadIndex = getPreviousGapLimit() - 1;
Integer highestUsed = node.getHighestUsedIndex();
if(highestUsed != null) {
lookAheadIndex = highestUsed + getPreviousGapLimit();
}
return lookAheadIndex;
}
}
@@ -76,6 +76,7 @@ public class FontAwesome5 extends GlyphFont {
TOGGLE_OFF('\uf204'),
TOGGLE_ON('\uf205'),
TOOLS('\uf7d9'),
UP_RIGHT_FROM_SQUARE('\uf35d'),
UNDO('\uf0e2'),
USER('\uf007'),
USER_FRIENDS('\uf500'),
@@ -0,0 +1,253 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.io.CharStreams;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Pbkdf2KeyDeriver;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.*;
import javax.crypto.*;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.Arrays;
import java.util.List;
public class Bip129 implements KeystoreFileExport, KeystoreFileImport, WalletExport, WalletImport {
@Override
public String getName() {
return "BSMS";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.BSMS;
}
@Override
public String getKeystoreExportDescription() {
return "Exports the keystore in the Bitcoin Secure Multisig Setup (BSMS) format.";
}
@Override
public void exportKeystore(Keystore keystore, OutputStream outputStream) throws ExportException {
if(!keystore.isValid()) {
throw new ExportException("Invalid keystore");
}
try {
String record = "BSMS 1.0\n00\n[" +
keystore.getKeyDerivation().toString() +
"]" +
keystore.getExtendedPublicKey().toString() +
"\n" +
keystore.getLabel();
outputStream.write(record.getBytes(StandardCharsets.UTF_8));
} catch(Exception e) {
throw new ExportException("Error writing BSMS file", e);
}
}
@Override
public boolean requiresSignature() {
//Due to poor vendor support of multiline message signing at the xpub derivation path, signing BSMS keystore exports is configurable (default false)
return Config.get().isSignBsmsExports();
}
@Override
public void addSignature(Keystore keystore, String signature, OutputStream outputStream) throws ExportException {
try {
String append = "\n" + signature;
outputStream.write(append.getBytes(StandardCharsets.UTF_8));
} catch(Exception e) {
throw new ExportException("Error writing BSMS file", e);
}
}
@Override
public String getExportFileExtension(Keystore keystore) {
return "bsms";
}
@Override
public boolean isKeystoreExportScannable() {
return true;
}
@Override
public boolean isEncrypted(File file) {
try {
try(BufferedReader reader = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8))) {
String text = CharStreams.toString(reader);
return Utils.isHex(text.trim());
}
} catch(Exception e) {
return false;
}
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
if(password != null) {
reader = decryptImport(password, reader);
}
String header = reader.readLine();
String token = reader.readLine();
String descriptor = reader.readLine();
String label = reader.readLine();
String signature = reader.readLine();
return getKeystore(header, token, descriptor, label, signature);
} catch(MnemonicException.MnemonicWordException e) {
throw new ImportException("Error importing BSMS: Invalid mnemonic word " + e.badWord, e);
} catch(MnemonicException.MnemonicChecksumException e) {
throw new ImportException("Error importing BSMS: Invalid mnemonic checksum", e);
} catch(Exception e) {
throw new ImportException("Error importing BSMS", e);
}
}
private BufferedReader decryptImport(String password, BufferedReader streamReader) throws Exception {
byte[] token;
if((password.length() == 16 || password.length() == 32) && Utils.isHex(password)) {
token = Utils.hexToBytes(password);
} else if(Utils.isNumber(password)) {
BigInteger bi = new BigInteger(password);
token = Utils.bigIntegerToBytes(bi, bi.toByteArray().length >= 16 ? 16 : 8);
} else if(password.split(" ").length == 6 || password.split(" ").length == 12) {
List<String> mnemonicWords = Arrays.asList(password.split(" "));
token = Bip39MnemonicCode.INSTANCE.toEntropy(mnemonicWords);
} else {
throw new ImportException("Provided password needs to be in hexadecimal, decimal or mnemonic format.");
}
String hex = CharStreams.toString(streamReader).trim();
byte[] data = Utils.hexToBytes(hex);
byte[] mac = Arrays.copyOfRange(data, 0, 32);
byte[] iv = Arrays.copyOfRange(mac, 0, 16);
byte[] ciphertext = Arrays.copyOfRange(data, 32, data.length);
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
Pbkdf2KeyDeriver pbkdf2KeyDeriver = new Pbkdf2KeyDeriver(token, 2048, 256);
byte[] key = pbkdf2KeyDeriver.deriveKey("No SPOF").getKeyBytes();
Key keySpec = new SecretKeySpec(key, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
byte[] plaintext = cipher.doFinal(ciphertext);
String plaintextString = new String(plaintext, StandardCharsets.UTF_8);
SecretKeySpec secretKeySpec = new SecretKeySpec(Sha256Hash.hash(key), "HmacSHA256");
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(secretKeySpec);
String macData = Utils.bytesToHex(token) + plaintextString;
byte[] calculatedMac = hmac.doFinal(macData.getBytes(StandardCharsets.UTF_8));
if(!Arrays.equals(mac, calculatedMac)) {
throw new ImportException("Message digest authentication failed.");
}
return new BufferedReader(new StringReader(plaintextString));
}
private Keystore getKeystore(String header, String token, String descriptor, String label, String signature) throws ImportException {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor("sh(" + descriptor + ")");
Wallet wallet = outputDescriptor.toWallet();
Keystore keystore = wallet.getKeystores().get(0);
keystore.setLabel(label);
if(signature != null) {
try {
String message = header + "\n" + token + "\n" + descriptor + "\n" + label;
keystore.getExtendedPublicKey().getKey().verifyMessage(message, signature);
} catch(SignatureException e) {
throw new ImportException("Signature did not match provided public key", e);
}
}
return keystore;
}
@Override
public String getKeystoreImportDescription(int account) {
return "Imports a keystore that was exported using the Bitcoin Secure Multisig Setup (BSMS) format.";
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
String record = "BSMS 1.0\n" +
OutputDescriptor.getOutputDescriptor(wallet) +
"\n/0/*,/1/*\n" +
wallet.getNode(KeyPurpose.RECEIVE).getChildren().iterator().next().getAddress();
outputStream.write(record.getBytes(StandardCharsets.UTF_8));
} catch(Exception e) {
throw new ExportException("Error exporting BSMS format", e);
}
}
@Override
public String getWalletExportDescription() {
return "Exports a multisig wallet in the Bitcoin Secure Multisig Setup (BSMS) format for import by other signers in the quorum.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "bsms";
}
@Override
public boolean isWalletExportScannable() {
return true;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public String getWalletImportDescription() {
return "Imports a multisig wallet in the Bitcoin Secure Multisig Setup (BSMS) format that has been created by another signer in the quorum.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
if(password != null) {
reader = decryptImport(password, reader);
}
String header = reader.readLine();
String descriptor = reader.readLine();
String paths = reader.readLine();
String address = reader.readLine();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(descriptor);
return outputDescriptor.toWallet();
} catch(Exception e) {
throw new ImportException("Error importing BSMS format", e);
}
}
@Override
public boolean isWalletImportScannable() {
return true;
}
}
@@ -17,6 +17,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.smartcardio.CardException;
import javax.smartcardio.CardTerminal;
import javax.smartcardio.CardTerminals;
import javax.smartcardio.TerminalFactory;
import java.io.File;
@@ -90,21 +91,27 @@ public abstract class CardApi {
public abstract void disconnect();
public static boolean isReaderAvailable() {
return !getAvailableTerminals().isEmpty();
}
public static List<CardTerminal> getAvailableTerminals() {
setLibrary();
try {
TerminalFactory tf = TerminalFactory.getDefault();
return !tf.terminals().list().isEmpty();
return tf.terminals().list();
} catch(Exception e) {
Throwable cause = Throwables.getRootCause(e);
if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) {
recoverNoService();
} else if(cause.getMessage().equals("SCARD_E_NO_READERS_AVAILABLE")) {
log.info("Error detecting card terminals", e);
} else {
log.error("Error detecting card terminals", e);
}
}
return false;
return Collections.emptyList();
}
private static void recoverNoService() {
@@ -30,6 +30,7 @@ public class Config {
private Mode mode;
private BitcoinUnit bitcoinUnit;
private UnitFormat unitFormat;
private Server blockExplorer;
private FeeRatesSource feeRatesSource;
private FeeRatesSelection feeRatesSelection;
private OptimizationStrategy sendOptimizationStrategy;
@@ -48,6 +49,7 @@ public class Config {
private boolean showLoadingLog = true;
private boolean showAddressTransactionCount = false;
private boolean showDeprecatedImportExport = false;
private boolean signBsmsExports = false;
private boolean preventSleep = false;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
@@ -149,6 +151,19 @@ public class Config {
flush();
}
public boolean isBlockExplorerDisabled() {
return BlockExplorer.NONE.getServer().equals(blockExplorer);
}
public Server getBlockExplorer() {
return blockExplorer;
}
public void setBlockExplorer(Server blockExplorer) {
this.blockExplorer = blockExplorer;
flush();
}
public FeeRatesSource getFeeRatesSource() {
return feeRatesSource;
}
@@ -315,6 +330,15 @@ public class Config {
flush();
}
public boolean isSignBsmsExports() {
return signBsmsExports;
}
public void setSignBsmsExports(boolean signBsmsExports) {
this.signBsmsExports = signBsmsExports;
flush();
}
public boolean isPreventSleep() {
return preventSleep;
}
@@ -136,6 +136,6 @@ public class Descriptor implements WalletImport, WalletExport {
@Override
public boolean isWalletImportScannable() {
return false;
return true;
}
}
@@ -36,7 +36,7 @@ public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.2.0";
private static final String HWI_VERSION = "2.2.1";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -278,7 +278,7 @@ public class Hwi {
} catch(IOException e) {
throw new SignTransactionException("Could not sign PSBT", e);
} catch(PSBTParseException e) {
throw new SignTransactionException("Could not parse signed PSBT", e);
throw new SignTransactionException("Could not parse signed PSBT" + (e.getMessage() != null ? ": " + e.getMessage() : ""), e);
} finally {
isPromptActive = false;
}
@@ -290,9 +290,7 @@ public class Hwi {
try {
ProcessBuilder processBuilder = new ProcessBuilder(command);
process = processBuilder.start();
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
return getProcessOutput(process);
} finally {
deleteExtractionOnFailure(process, start);
}
@@ -303,29 +301,54 @@ public class Hwi {
Process process = null;
try {
List<String> processArguments = new ArrayList<>(arguments);
processArguments.add("--stdin");
boolean useStdin = Arrays.stream(commandArguments).noneMatch(arg -> arg.contains("\n"));
if(useStdin) {
processArguments.add("--stdin");
} else {
processArguments.add(command.toString());
processArguments.addAll(Arrays.asList(commandArguments));
}
ProcessBuilder processBuilder = new ProcessBuilder(processArguments);
process = processBuilder.start();
try(BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8))) {
writer.write(command.toString());
for(String commandArgument : commandArguments) {
writer.write(" \"");
writer.write(commandArgument.replace("\\", "\\\\").replace("\"", "\\\""));
writer.write("\"");
if(useStdin) {
try(BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8))) {
writer.write(command.toString());
for(String commandArgument : commandArguments) {
writer.write(" \"");
writer.write(commandArgument.replace("\\", "\\\\").replace("\"", "\\\""));
writer.write("\"");
}
writer.flush();
}
writer.flush();
}
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
return getProcessOutput(process);
} finally {
deleteExtractionOnFailure(process, start);
}
}
private String getProcessOutput(Process process) throws IOException {
String output;
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
output = CharStreams.toString(reader);
}
if(output.isEmpty() && process.getErrorStream() != null) {
try(InputStreamReader reader = new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8)) {
String errorOutput = CharStreams.toString(reader);
if(!errorOutput.isEmpty()) {
throw new IOException(errorOutput);
}
}
}
return output;
}
private synchronized File getHwiExecutable(Command command) {
File hwiExecutable = Config.get().getHwi();
if(hwiExecutable != null && hwiExecutable.exists()) {
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.io;
public interface KeystoreExport extends ImportExport {
String getKeystoreExportDescription();
}
@@ -0,0 +1,16 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.Keystore;
import java.io.OutputStream;
public interface KeystoreFileExport extends KeystoreExport {
void exportKeystore(Keystore keystore, OutputStream outputStream) throws ExportException;
boolean requiresSignature();
void addSignature(Keystore keystore, String signature, OutputStream outputStream) throws ExportException;
String getExportFileExtension(Keystore keystore);
boolean isKeystoreExportScannable();
default boolean isKeystoreExportFile() {
return true;
}
}
@@ -9,10 +9,14 @@ import com.google.zxing.qrcode.QRCodeWriter;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.Image;
import com.lowagie.text.Rectangle;
import com.lowagie.text.alignment.HorizontalAlignment;
import com.lowagie.text.alignment.VerticalAlignment;
import com.lowagie.text.pdf.PdfReader;
import com.lowagie.text.pdf.PdfWriter;
import com.lowagie.text.pdf.parser.PdfTextExtractor;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
@@ -25,7 +29,8 @@ import org.slf4j.LoggerFactory;
import java.awt.*;
import java.io.*;
import java.util.Scanner;
import java.util.*;
import java.util.List;
public class PdfUtils {
private static final Logger log = LoggerFactory.getLogger(PdfUtils.class);
@@ -99,7 +104,7 @@ public class PdfUtils {
private static javafx.scene.image.Image getQrCode(String fragment) throws IOException, WriterException {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
@@ -107,4 +112,106 @@ public class PdfUtils {
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
return new javafx.scene.image.Image(bais);
}
public static String[][] getWordGrid(InputStream inputStream) {
try {
PdfReader pdfReader = new PdfReader(inputStream);
String allText = "";
for(int page = 1; page <= pdfReader.getNumberOfPages(); page++) {
PdfTextExtractor textExtractor = new PdfTextExtractor(pdfReader);
allText += textExtractor.getTextFromPage(page) + "\n";
}
List<String[]> rows = new ArrayList<>();
Scanner scanner = new Scanner(allText);
while(scanner.hasNextLine()) {
String line = scanner.nextLine().trim();
String[] words = line.split(" ");
if(words.length > 16 && Utils.isNumber(words[0])) {
rows.add(Arrays.copyOfRange(words, 1, 17));
}
}
if(rows.size() < 128) {
throw new IllegalArgumentException("Not a valid Border Wallets PDF");
}
return rows.toArray(new String[][]{new String[0]});
} catch(Exception e) {
throw new IllegalArgumentException("Not a valid Border Wallets PDF");
}
}
public static void saveWordGrid(String[][] wordGrid, List<String> mnemonicWords) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save Border Wallet Grid");
fileChooser.setInitialFileName("BorderWalletEntropyGrid.pdf");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(Document document = new Document()) {
document.setMargins(0, 0, 10, 10);
PdfWriter.getInstance(document, new FileOutputStream(file));
Font headerFont = new Font(Font.HELVETICA, 8, Font.BOLD, Color.DARK_GRAY);
Font font = new Font(Font.HELVETICA, 8, Font.NORMAL, Color.DARK_GRAY);
HeaderFooter footer = new HeaderFooter(false, new Phrase("Recovery Phrase (to regenerate grid): " + String.join(" ", mnemonicWords), font));
footer.setAlignment(Element.ALIGN_CENTER);
footer.setBorder(Rectangle.NO_BORDER);
footer.setBorderWidth(0);
document.setFooter(footer);
document.open();
int rowCount = wordGrid.length;
int columnCount = wordGrid[0].length;
Table table = new Table(columnCount + 2);
table.setBorderWidth(0.5f);
table.setBorderColor(new Color(208, 208, 208));
table.setBorderWidthLeft(0);
table.setBorderWidthRight(0);
List<String> headers = List.of(" ", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", " ");
for(String header : headers) {
Paragraph paragraph = new Paragraph(header, headerFont);
Cell cell = new Cell(paragraph);
cell.setBackgroundColor(new Color(218, 218, 218));
cell.setHorizontalAlignment(HorizontalAlignment.CENTER);
cell.setVerticalAlignment(VerticalAlignment.CENTER);
cell.setBorderWidth(0.5f);
cell.setBorderColor(new Color(208, 208, 208));
cell.setHeader(true);
table.addCell(cell);
}
table.endHeaders();
for(int i = 0; i < rowCount; i++) {
for(int j = 0; j < columnCount + 2; j++) {
Cell cell;
if(j == 0 || j == columnCount + 1) {
cell = new Cell(new Paragraph(String.format("%03d", i + 1), headerFont));
cell.setBackgroundColor(new Color(218, 218, 218));
} else {
cell = new Cell(new Paragraph(wordGrid[i][j-1], font));
}
cell.setHorizontalAlignment(HorizontalAlignment.CENTER);
cell.setVerticalAlignment(VerticalAlignment.CENTER);
cell.setBorderWidth(0.5f);
cell.setBorderColor(new Color(208, 208, 208));
table.addCell(cell);
}
}
document.add(table);
} catch(Exception e) {
log.error("Error creating word grid PDF", e);
AppServices.showErrorDialog("Error creating word grid PDF", e.getMessage());
}
} else {
AppServices.showWarningDialog("Entropy Grid PDF not saved", "You have chosen to not save the entropy grid PDF.\n\nDo not store funds on a seed selected from this grid - you will not be able to regenerate it!");
}
}
}
@@ -10,7 +10,7 @@ public class SeedSigner extends SpecterDIY {
@Override
public String getKeystoreImportDescription(int account) {
return "Import QR created on your SeedSigner by selecting xPub from Seed in the Seed Tools menu once you have entered your seed.";
return "Import QR created on your SeedSigner by selecting Export Xpub in the Seeds menu once you have entered your seed.";
}
@Override
@@ -70,6 +70,14 @@ public class Server {
return url + (alias == null ? "" : "|" + alias);
}
public boolean portEquals(String port) {
if(port == null) {
return !getHostAndPort().hasPort();
}
return port.equals(getHostAndPort().hasPort() ? Integer.toString(getHostAndPort().getPort()) : "");
}
public static Server fromString(String server) {
String[] parts = server.split("\\|");
if(parts.length >= 2) {
@@ -1,7 +1,6 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.*;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
@@ -12,6 +11,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
public class SpecterDesktop implements WalletImport, WalletExport {
@@ -22,7 +22,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
try {
SpecterWallet specterWallet = new SpecterWallet();
specterWallet.label = wallet.getFullName();
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight());
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight() == null ? 0 : wallet.getStoredBlockHeight());
specterWallet.descriptor = OutputDescriptor.getOutputDescriptor(wallet).toString(true);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -54,7 +54,44 @@ public class SpecterDesktop implements WalletImport, WalletExport {
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
Gson gson = new Gson();
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), SpecterWallet.class);
JsonObject jsonObj = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), JsonElement.class).getAsJsonObject();
SpecterWallet specterWallet = new SpecterWallet();
if(jsonObj.get("descriptor") != null) {
specterWallet.descriptor = jsonObj.get("descriptor").getAsString();
} else if(jsonObj.get("recv_descriptor") != null) {
specterWallet.descriptor = jsonObj.get("recv_descriptor").getAsString();
}
if(jsonObj.get("label") != null) {
specterWallet.label = jsonObj.get("label").getAsString();
} else if(jsonObj.get("name") != null) {
specterWallet.label = jsonObj.get("name").getAsString();
}
if(jsonObj.get("blockheight") != null) {
specterWallet.blockheight = jsonObj.get("blockheight").getAsInt();
}
if(jsonObj.get("devices") != null) {
JsonArray jsonDevices = jsonObj.get("devices").getAsJsonArray();
specterWallet.devices = new ArrayList<>();
for(JsonElement jsonDevice : jsonDevices) {
SpecterWalletDevice specterWalletDevice = new SpecterWalletDevice();
if(jsonDevice.isJsonObject()) {
JsonObject jsonDeviceObj = (JsonObject)jsonDevice;
if(jsonDeviceObj.get("label") != null) {
specterWalletDevice.label = jsonDeviceObj.get("label").getAsString();
}
if(jsonDeviceObj.get("type") != null) {
specterWalletDevice.type = jsonDeviceObj.get("type").getAsString();
}
} else if(jsonDevice.isJsonPrimitive()) {
specterWalletDevice.label = jsonDevice.getAsString();
}
specterWallet.devices.add(specterWalletDevice);
}
}
if(specterWallet.descriptor != null) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(specterWallet.descriptor);
@@ -80,6 +117,9 @@ public class SpecterDesktop implements WalletImport, WalletExport {
} else {
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
}
} else {
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setSource(KeystoreSource.SW_WATCH);
}
}
}
@@ -8,6 +8,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,29 +55,32 @@ public class WalletLabels implements WalletImport, WalletExport {
for(Keystore keystore : exportWallet.getKeystores()) {
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null));
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null, null));
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin));
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
}
}
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null));
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null));
}
}
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true");
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null));
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable));
} else if(!txo.isSpent()) {
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
}
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null));
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
}
}
}
@@ -140,7 +144,15 @@ public class WalletLabels implements WalletImport, WalletExport {
continue;
}
if(label == null || label.type == null || label.ref == null || label.label == null || label.label.isEmpty()) {
if(label == null || label.type == null || label.ref == null) {
continue;
}
if(label.type == Type.output) {
if((label.label == null || label.label.isEmpty()) && label.spendable == null) {
continue;
}
} else if(label.label == null || label.label.isEmpty()) {
continue;
}
@@ -152,6 +164,7 @@ public class WalletLabels implements WalletImport, WalletExport {
Map<Wallet, List<Keystore>> changedWalletKeystores = new LinkedHashMap<>();
Map<Wallet, List<Entry>> changedWalletEntries = new LinkedHashMap<>();
Map<Wallet, List<BlockTransactionHashIndex>> changedWalletUtxoStatuses = new LinkedHashMap<>();
for(WalletForm walletForm : walletForms) {
Wallet wallet = walletForm.getWallet();
@@ -216,9 +229,21 @@ public class WalletLabels implements WalletImport, WalletExport {
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
txioEntry.getHashIndex().setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
if(label.label != null && !label.label.isEmpty()) {
reference.setLabel(label.label);
txioEntry.labelProperty().set(label.label);
addChangedEntry(changedWalletEntries, txioEntry);
}
if(label.type == Type.output && !reference.isSpent()) {
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
reference.setStatus(Status.FROZEN);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
reference.setStatus(null);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
}
}
}
}
}
@@ -248,6 +273,10 @@ public class WalletLabels implements WalletImport, WalletExport {
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletEntries.getKey(), walletEntries.getValue(), false));
}
for(Map.Entry<Wallet, List<BlockTransactionHashIndex>> walletUtxos : changedWalletUtxoStatuses.entrySet()) {
EventManager.get().post(new WalletUtxoStatusChangedEvent(walletUtxos.getKey(), walletUtxos.getValue()));
}
return walletForms.get(0).getWallet();
}
@@ -256,7 +285,9 @@ public class WalletLabels implements WalletImport, WalletExport {
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
hashIndexEntry.labelProperty().set(label.label);
if(label.label != null && !label.label.isEmpty()) {
hashIndexEntry.labelProperty().set(label.label);
}
}
}
}
@@ -266,6 +297,11 @@ public class WalletLabels implements WalletImport, WalletExport {
entries.add(entry);
}
private static void addChangedUtxo(Map<Wallet, List<BlockTransactionHashIndex>> changedUtxos, TransactionHashIndexEntry utxoEntry) {
List<BlockTransactionHashIndex> utxos = changedUtxos.computeIfAbsent(utxoEntry.getWallet(), w -> new ArrayList<>());
utxos.add(utxoEntry.getHashIndex());
}
@Override
public boolean isWalletImportScannable() {
return false;
@@ -281,16 +317,18 @@ public class WalletLabels implements WalletImport, WalletExport {
}
private static class Label {
public Label(Type type, String ref, String label, String origin) {
public Label(Type type, String ref, String label, String origin, String spendable) {
this.type = type;
this.ref = ref;
this.label = label;
this.origin = origin;
this.spendable = spendable;
}
Type type;
String ref;
String label;
String origin;
String spendable;
}
}
@@ -20,8 +20,7 @@ import org.slf4j.LoggerFactory;
import javax.smartcardio.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.*;
import java.util.Map;
public class CardTransport {
@@ -41,15 +40,32 @@ public class CardTransport {
throw new IllegalStateException("No reader connected");
}
CardTerminal cardTerminal = (CardTerminal)terminals.get(0);
connection = cardTerminal.connect("*");
Card connection = null;
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
try {
connection = getConnection(iter.next());
break;
} catch(CardException e) {
if(!iter.hasNext()) {
log.error(e.getMessage());
throw e;
}
}
}
this.connection = connection;
}
private Card getConnection(CardTerminal cardTerminal) throws CardException {
Card connection = cardTerminal.connect("*");
CardChannel cardChannel = connection.getBasicChannel();
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
if(resp.getSW() != SW_OKAY) {
log.error("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported.");
}
return connection;
}
JsonObject send(String cmd, Map<String, Object> args) throws CardException {
@@ -23,10 +23,7 @@ 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;
import java.util.*;
public class CkCardApi extends CardApi {
private static final Logger log = LoggerFactory.getLogger(CkCardApi.class);
@@ -227,8 +224,18 @@ public class CkCardApi extends CardApi {
}
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());
List<ChildNumber> keystoreDerivation;
List<ChildNumber> subPathDerivation;
Optional<ChildNumber> firstUnhardened = derivation.stream().filter(cn -> !cn.isHardened()).findFirst();
if(firstUnhardened.isPresent()) {
int index = derivation.indexOf(firstUnhardened.get());
keystoreDerivation = derivation.subList(0, index);
subPathDerivation = derivation.subList(index, derivation.size());
} else {
keystoreDerivation = derivation;
subPathDerivation = Collections.emptyList();
}
Keystore cardKeystore = getKeystore();
KeyDerivation cardKeyDerivation = cardKeystore.getKeyDerivation();
@@ -239,9 +246,15 @@ public class CkCardApi extends CardApi {
signingKeystore = getKeystore();
}
WalletNode addressNode = new WalletNode(KeyDerivation.writePath(subPathDerivation));
ECKey addressPubKey = signingKeystore.getPubKey(addressNode);
return addressPubKey.signMessage(message, scriptType, hash -> {
ECKey signingPubKey;
if(subPathDerivation.isEmpty()) {
signingPubKey = signingKeystore.getExtendedPublicKey().getKey();
} else {
WalletNode addressNode = new WalletNode(KeyDerivation.writePath(subPathDerivation));
signingPubKey = signingKeystore.getPubKey(addressNode);
}
return signingPubKey.signMessage(message, scriptType, hash -> {
try {
CardSign cardSign = cardProtocol.sign(cvc, subPathDerivation, hash);
return cardSign.getSignature();
@@ -15,8 +15,6 @@ 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);
@@ -28,7 +26,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
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)) {
fileImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
}
for(KeystoreFileImport importer : fileImporters) {
@@ -40,10 +38,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
}
}
List<KeystoreCardImport> cardImporters = Collections.emptyList();
if(isReaderAvailable()) {
cardImporters = List.of(new Tapsigner());
}
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner());
for(KeystoreCardImport importer : cardImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
@@ -128,7 +128,7 @@ public class Auth47 {
}
Proxy proxy = AppServices.getProxy();
if(proxy == null && callback.getHost().toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX)) {
if(proxy == null && callback.getHost().toLowerCase(Locale.ROOT).endsWith(Tor.TOR_ADDRESS_SUFFIX)) {
throw new Auth47Exception("A Tor proxy must be configured to authenticate this resource.");
}
@@ -4,6 +4,7 @@ import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
@@ -11,7 +12,7 @@ import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
@@ -227,23 +228,23 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
throw new ElectrumServerRpcException("Error getting fee estimates", e);
throw new ElectrumServerRpcException("Error getting fee estimates: " + e.getErrors(), e);
} catch(Exception e) {
throw new ElectrumServerRpcException("Error getting fee estimates for target blocks: " + targetBlocks, e);
}
}
@Override
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
public Map<Double, Long> getFeeRateHistogram(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
BigInteger[][] feesArray = new RetryLogic<BigInteger[][]>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigInteger[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
BigDecimal[][] feesArray = new RetryLogic<BigDecimal[][]>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigDecimal[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
Map<Long, Long> feeRateHistogram = new TreeMap<>();
for(BigInteger[] feePair : feesArray) {
Map<Double, Long> feeRateHistogram = new TreeMap<>();
for(BigDecimal[] feePair : feesArray) {
if(feePair[0].longValue() > 0) {
feeRateHistogram.put(feePair[0].longValue(), feePair[1].longValue());
feeRateHistogram.put(feePair[0].doubleValue(), feePair[1].longValue());
}
}
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.sparrow.io.Server;
public enum BlockExplorer {
MEMPOOL_SPACE("https://mempool.space"),
BLOCKSTREAM_INFO("https://blockstream.info"),
NONE("http://none");
private final Server server;
BlockExplorer(String url) {
this.server = new Server(url);
}
public Server getServer() {
return server;
}
}
@@ -56,6 +56,8 @@ public enum BroadcastSource {
return new URL(getBaseUrl(proxy) + "/api/tx");
} else if(Network.get() == Network.TESTNET) {
return new URL(getBaseUrl(proxy) + "/testnet/api/tx");
} else if(Network.get() == Network.SIGNET) {
return new URL(getBaseUrl(proxy) + "/signet/api/tx");
} else {
throw new IllegalStateException("Cannot broadcast transaction to " + getName() + " on network " + Network.get());
}
@@ -146,6 +146,9 @@ public class Bwt {
Config config = Config.get();
if(config.getCoreServer() != null) {
bwtConfig.bitcoindUrl = config.getCoreServer().getUrl();
if(!config.getCoreServer().getHostAndPort().hasPort()) {
bwtConfig.bitcoindUrl = config.getCoreServer().getUrl() + ":" + Network.get().getDefaultPort();
}
try {
Protocol protocol = config.getCoreServer().getProtocol();
HostAndPort hostAndPort = protocol.getServerHostAndPort(bwtConfig.bitcoindUrl);
@@ -181,7 +184,7 @@ public class Bwt {
private HostAndPort getTorProxy() {
return AppServices.isTorRunning() ?
HostAndPort.fromParts("127.0.0.1", TorService.PROXY_PORT) :
Tor.getDefault().getProxyHostAndPort() :
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer().replace("localhost", "127.0.0.1")));
}
@@ -692,7 +692,9 @@ public class ElectrumServer {
//First check all provided txes that pay to this node
Script nodeScript = node.getOutputScript();
Set<BlockTransactionHash> history = nodeTransactionMap.get(node);
Map<Sha256Hash, BlockTransactionHash> txHashHistory = new HashMap<>();
for(BlockTransactionHash reference : history) {
txHashHistory.put(reference.getHash(), reference);
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
if(blockTransaction == null) {
throw new IllegalStateException("Did not retrieve transaction for hash " + reference.getHashAsString());
@@ -731,14 +733,13 @@ public class ElectrumServer {
throw new IllegalStateException("Could not retrieve transaction for hash " + reference.getHashAsString());
}
Optional<BlockTransactionHash> optionalTxHash = history.stream().filter(txHash -> txHash.getHash().equals(previousHash)).findFirst();
if(optionalTxHash.isEmpty()) {
BlockTransactionHash spentTxHash = txHashHistory.get(previousHash);
if(spentTxHash == null) {
//No previous transaction history found, cannot check if spends from wallet
//This is fine so long as all referenced transactions have been returned, in which case this refers to a transaction that does not affect this wallet node
continue;
}
BlockTransactionHash spentTxHash = optionalTxHash.get();
TransactionOutput spentOutput = previousTransaction.getTransaction().getOutputs().get((int)input.getOutpoint().getIndex());
if(spentOutput.getScript().equals(nodeScript)) {
BlockTransactionHashIndex spendingTXI = new BlockTransactionHashIndex(reference.getHash(), reference.getHeight(), blockTransaction.getDate(), reference.getFee(), inputIndex, spentOutput.getValue());
@@ -835,9 +836,9 @@ public class ElectrumServer {
}
public Set<MempoolRateSize> getMempoolRateSizes() throws ServerException {
Map<Long, Long> feeRateHistogram = electrumServerRpc.getFeeRateHistogram(getTransport());
Map<Double, Long> feeRateHistogram = electrumServerRpc.getFeeRateHistogram(getTransport());
Set<MempoolRateSize> mempoolRateSizes = new TreeSet<>();
for(Long fee : feeRateHistogram.keySet()) {
for(Double fee : feeRateHistogram.keySet()) {
mempoolRateSizes.add(new MempoolRateSize(fee, feeRateHistogram.get(fee)));
}
@@ -1330,6 +1331,13 @@ public class ElectrumServer {
bwtStartLock.unlock();
}
}
@Subscribe
public void mempoolEntriesInitialized(MempoolEntriesInitializedEvent event) throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
EventManager.get().post(new MempoolRateSizesUpdatedEvent(mempoolRateSizes));
}
}
public static class ReadRunnable implements Runnable {
@@ -30,7 +30,7 @@ public interface ElectrumServerRpc {
Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks);
Map<Long, Long> getFeeRateHistogram(Transport transport);
Map<Double, Long> getFeeRateHistogram(Transport transport);
Double getMinimumRelayFee(Transport transport);
@@ -108,7 +108,7 @@ public class LnurlAuth {
}
Proxy proxy = AppServices.getProxy();
if(proxy == null && callback.getHost().toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX)) {
if(proxy == null && callback.getHost().toLowerCase(Locale.ROOT).endsWith(Tor.TOR_ADDRESS_SUFFIX)) {
throw new LnurlAuthException("A Tor proxy must be configured to authenticate this resource.");
}
@@ -3,15 +3,15 @@ package com.sparrowwallet.sparrow.net;
import java.util.Objects;
public class MempoolRateSize implements Comparable<MempoolRateSize> {
private final long fee;
private final double fee;
private final long vSize;
public MempoolRateSize(long fee, long vSize) {
public MempoolRateSize(double fee, long vSize) {
this.fee = fee;
this.vSize = vSize;
}
public long getFee() {
public double getFee() {
return fee;
}
@@ -38,7 +38,7 @@ public class MempoolRateSize implements Comparable<MempoolRateSize> {
@Override
public int compareTo(MempoolRateSize other) {
return Long.compare(fee, other.fee);
return Double.compare(fee, other.fee);
}
@Override
@@ -129,7 +129,9 @@ public enum Protocol {
public abstract CloseableTransport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public HostAndPort getServerHostAndPort(String url) {
return HostAndPort.fromString(url.substring(this.toUrlString().length()));
String lessProtocol = url.substring(this.toUrlString().length());
int pathStart = lessProtocol.indexOf('/');
return HostAndPort.fromString(pathStart < 0 ? lessProtocol : lessProtocol.substring(0, pathStart));
}
public String toUrlString() {
@@ -149,7 +151,7 @@ public enum Protocol {
}
public static boolean isOnionHost(String host) {
return host != null && host.toLowerCase(Locale.ROOT).endsWith(TorService.TOR_ADDRESS_SUFFIX);
return host != null && host.toLowerCase(Locale.ROOT).endsWith(Tor.TOR_ADDRESS_SUFFIX);
}
public static boolean isOnionAddress(Server server) {
@@ -32,7 +32,7 @@ public class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
protected void createSocket() throws IOException {
InetSocketAddress proxyAddr = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT));
socket = new Socket(new Proxy(Proxy.Type.SOCKS, proxyAddr));
socket.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(Protocol.SSL.getDefaultPort())));
socket.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(getDefaultPort())));
socket = sslSocketFactory.createSocket(socket, proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT), true);
startHandshake((SSLSocket)socket);
}
@@ -13,6 +13,7 @@ public enum PublicElectrumServer {
LUKECHILDS_CO("bitcoin.lu.ke", "ssl://bitcoin.lu.ke:50002", Network.MAINNET),
EMZY_DE("electrum.emzy.de", "ssl://electrum.emzy.de:50002", Network.MAINNET),
BITAROO_NET("electrum.bitaroo.net", "ssl://electrum.bitaroo.net:50002", Network.MAINNET),
DIYNODES_COM("electrum.diynodes.com", "ssl://electrum.diynodes.com:50022", Network.MAINNET),
TESTNET_ARANGUREN_ORG("testnet.aranguren.org", "ssl://testnet.aranguren.org:51002", Network.TESTNET);
PublicElectrumServer(String name, String url, Network network) {
@@ -13,7 +13,7 @@ import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigInteger;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
@@ -79,7 +79,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + path));
try {
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
result.put(path, scriptHashTxes);
} catch(Exception e) {
if(failOnError) {
@@ -101,7 +101,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
for(String path : pathScriptHashes.keySet()) {
try {
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
result.put(path, scriptHashTxes);
} catch(Exception e) {
if(failOnError) {
@@ -124,7 +124,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path));
try {
String scriptHash = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
result.put(path, scriptHash);
} catch(Exception e) {
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
@@ -260,16 +260,16 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
public Map<Double, Long> getFeeRateHistogram(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
BigInteger[][] feesArray = new RetryLogic<BigInteger[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigInteger[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
BigDecimal[][] feesArray = new RetryLogic<BigDecimal[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(BigDecimal[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
Map<Long, Long> feeRateHistogram = new TreeMap<>();
for(BigInteger[] feePair : feesArray) {
Map<Double, Long> feeRateHistogram = new TreeMap<>();
for(BigDecimal[] feePair : feesArray) {
if(feePair[0].longValue() > 0) {
feeRateHistogram.put(feePair[0].longValue(), feePair[1].longValue());
feeRateHistogram.put(feePair[0].doubleValue(), feePair[1].longValue());
}
}
@@ -125,4 +125,9 @@ public class TcpOverTlsTransport extends TcpTransport {
return Storage.getCertificateFile(server.getHost()) == null;
}
@Override
protected int getDefaultPort() {
return Protocol.SSL.getDefaultPort();
}
}
@@ -254,7 +254,11 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
protected void createSocket() throws IOException {
socket = socketFactory.createSocket();
socket.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(Protocol.TCP.getDefaultPort())));
socket.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(getDefaultPort())));
}
protected int getDefaultPort() {
return Protocol.TCP.getDefaultPort();
}
public boolean isClosed() {
@@ -0,0 +1,158 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import com.sparrowwallet.sparrow.io.Storage;
import io.matthewnelson.kmp.tor.KmpTorLoaderJvm;
import io.matthewnelson.kmp.tor.PlatformInstaller;
import io.matthewnelson.kmp.tor.TorConfigProviderJvm;
import io.matthewnelson.kmp.tor.binary.extract.TorBinaryResource;
import io.matthewnelson.kmp.tor.common.address.Port;
import io.matthewnelson.kmp.tor.common.address.ProxyAddress;
import io.matthewnelson.kmp.tor.controller.common.config.TorConfig;
import io.matthewnelson.kmp.tor.controller.common.file.Path;
import io.matthewnelson.kmp.tor.ext.callback.manager.CallbackTorManager;
import io.matthewnelson.kmp.tor.manager.TorManager;
import io.matthewnelson.kmp.tor.manager.common.event.TorManagerEvent;
import io.matthewnelson.kmp.tor.manager.util.PortUtil;
import org.controlsfx.tools.Platform;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.List;
public class Tor {
private static final Logger log = LoggerFactory.getLogger(Tor.class);
public static final String TOR_DIR = "tor";
public static final String TOR_ADDRESS_SUFFIX = ".onion";
private static Tor tor;
private final Path path = Path.invoke(Storage.getSparrowHome().getAbsolutePath()).builder().addSegment(TOR_DIR).build();
private final CallbackTorManager instance;
private ProxyAddress socksAddress;
public Tor() {
Platform platform = Platform.getCurrent();
String arch = System.getProperty("os.arch");
PlatformInstaller installer;
PlatformInstaller.InstallOption installOption = PlatformInstaller.InstallOption.CleanInstallIfMissing;
if(platform == Platform.OSX) {
if(arch.equals("aarch64")) {
installer = PlatformInstaller.macosArm64(installOption);
} else {
installer = PlatformInstaller.macosX64(installOption);
}
} else if(platform == Platform.WINDOWS) {
installer = PlatformInstaller.mingwX64(installOption);
} else if(platform == Platform.UNIX) {
if(arch.equals("aarch64")) {
TorBinaryResource linuxArm64 = TorBinaryResource.from(TorBinaryResource.OS.Linux, "arm64",
"588496f3164d52b91f17e4db3372d8dfefa6366a8df265eebd4a28d4128992aa",
List.of("libevent-2.1.so.7.gz", "libstdc++.so.6.gz", "libcrypto.so.1.1.gz", "tor.gz", "libssl.so.1.1.gz"));
installer = PlatformInstaller.custom(installOption, linuxArm64);
} else {
installer = PlatformInstaller.linuxX64(installOption);
}
} else {
throw new UnsupportedOperationException("Sparrow's bundled Tor is not supported on " + platform + " " + arch);
}
TorConfigProviderJvm torConfigProviderJvm = new TorConfigProviderJvm() {
@NotNull
@Override
public Path getWorkDir() {
return path.builder().addSegment("work").build();
}
@NotNull
@Override
public Path getCacheDir() {
return path.builder().addSegment("cache").build();
}
@NotNull
@Override
protected TorConfig provide() {
TorConfig.Builder builder = new TorConfig.Builder();
TorConfig.Setting.DormantCanceledByStartup dormantCanceledByStartup = new TorConfig.Setting.DormantCanceledByStartup();
dormantCanceledByStartup.set(TorConfig.Option.AorTorF.getTrue());
builder.put(dormantCanceledByStartup);
return builder.build();
}
};
KmpTorLoaderJvm jvmLoader = new KmpTorLoaderJvm(installer, torConfigProviderJvm);
TorManager torManager = TorManager.newInstance(jvmLoader);
torManager.debug(true);
torManager.addListener(new TorManagerEvent.Listener() {
@Override
public void managerEventWarn(@NotNull String message) {
log.warn(message);
EventManager.get().post(new TorStatusEvent(message));
}
@Override
public void managerEventInfo(@NotNull String message) {
log.info(message);
EventManager.get().post(new TorStatusEvent(message));
}
@Override
public void managerEventDebug(@NotNull String message) {
log.debug(message);
EventManager.get().post(new TorStatusEvent(message));
}
@Override
public void managerEventAddressInfo(@NotNull TorManagerEvent.AddressInfo info) {
if(info.isNull) {
socksAddress = null;
} else {
socksAddress = info.socksInfoToProxyAddress().iterator().next();
}
}
});
this.instance = new CallbackTorManager(torManager, uncaughtException -> {
log.error("Uncaught exception from CallbackTorManager", uncaughtException);
});
}
public static Tor getDefault() {
return Tor.tor;
}
public static void setDefault(Tor tor) {
Tor.tor = tor;
}
public CallbackTorManager getTorManager() {
return instance;
}
public Proxy getProxy() {
if(socksAddress == null) {
throw new IllegalStateException("Tor socks proxy is not yet instantiated");
}
return new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksAddress.address.getValue(), socksAddress.port.getValue()));
}
public HostAndPort getProxyHostAndPort() {
return HostAndPort.fromParts(socksAddress.address.getValue(), socksAddress.port.getValue());
}
public static boolean isRunningExternally() {
return !PortUtil.isTcpPortAvailable(Port.invoke(9050));
}
}
@@ -1,11 +0,0 @@
package com.sparrowwallet.sparrow.net;
public class TorServerAlreadyBoundException extends TorServerException {
public TorServerAlreadyBoundException(Throwable cause) {
super(cause);
}
public TorServerAlreadyBoundException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -5,6 +5,10 @@ public class TorServerException extends ServerException {
super(cause);
}
public TorServerException(String message) {
super(message);
}
public TorServerException(String message, Throwable cause) {
super(message, cause);
}
@@ -1,86 +1,85 @@
package com.sparrowwallet.sparrow.net;
import io.matthewnelson.kmp.tor.ext.callback.manager.CallbackTorManager;
import io.matthewnelson.kmp.tor.manager.common.event.TorManagerEvent;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import net.freehaven.tor.control.TorControlError;
import org.berndpruenster.netlayer.tor.*;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* Service to start internal Tor (including a Tor proxy running on localhost:9050)
* Service to start internal Tor (including a Tor proxy running on localhost:9050, or another port if unavailable)
*
* This is a ScheduledService to take advantage of the retry on failure behaviour
*/
public class TorService extends ScheduledService<NativeTor> {
public class TorService extends ScheduledService<Tor> {
private static final Logger log = LoggerFactory.getLogger(TorService.class);
public static final int PROXY_PORT = 9050;
public static final String TOR_DIR_PREFIX = "tor";
public static final String TOR_ADDRESS_SUFFIX = ".onion";
private final ReentrantLock startupLock = new ReentrantLock();
private final Condition startupCondition = startupLock.newCondition();
@Override
protected Task<NativeTor> createTask() {
protected Task<Tor> createTask() {
return new Task<>() {
protected NativeTor call() throws IOException, TorServerException {
if(Tor.getDefault() == null) {
Path path = Files.createTempDirectory(TOR_DIR_PREFIX);
File torInstallDir = path.toFile();
torInstallDir.deleteOnExit();
try {
LinkedHashMap<String, String> torrcOptionsMap = new LinkedHashMap<>();
torrcOptionsMap.put("SocksPort", Integer.toString(PROXY_PORT));
torrcOptionsMap.put("HashedControlPassword", "16:D780432418F09B06609940000924317D3B9DF522A3191F8F4E597E9329");
torrcOptionsMap.put("DisableNetwork", "0");
Torrc override = new Torrc(torrcOptionsMap);
private Exception startupException;
return new NativeTor(torInstallDir, Collections.emptyList(), override);
} catch(TorCtlException e) {
if(e.getCause() instanceof TorControlError) {
if(e.getCause().getMessage().contains("Failed to bind")) {
throw new TorServerAlreadyBoundException("Tor server already bound", e.getCause());
@Override
protected Tor call() throws Exception {
Tor tor = Tor.getDefault();
if(tor == null) {
tor = new Tor();
CallbackTorManager callbackTorManager = tor.getTorManager();
callbackTorManager.addListener(new TorManagerEvent.Listener() {
@Override
public void managerEventAddressInfo(@NotNull TorManagerEvent.AddressInfo info) {
if(!info.isNull) {
try {
startupLock.lock();
startupCondition.signalAll();
} finally {
startupLock.unlock();
}
}
log.error("Failed to start Tor", e);
throw new TorServerException("Failed to start Tor", e.getCause());
} else {
log.error("Failed to start Tor", e);
throw new TorServerException("Failed to start Tor", e);
}
});
callbackTorManager.start(throwable -> {
if(throwable instanceof Exception exception) {
startupException = exception;
} else {
startupException = new Exception(throwable);
}
log.error("Error", throwable);
try {
startupLock.lock();
startupCondition.signalAll();
} finally {
startupLock.unlock();
}
}, success -> {
log.info("Tor daemon started successfully");
});
try {
startupLock.lock();
if(!startupCondition.await(5, TimeUnit.MINUTES)) {
throw new TorStartupException("Tor failed to start after 5 minutes, giving up");
}
if(startupException != null) {
throw startupException;
}
} finally {
startupLock.unlock();
}
}
return null;
return tor;
}
};
}
public static Socket getControlSocket() {
Tor tor = Tor.getDefault();
if(tor != null) {
try {
Class<?> torClass = Class.forName("org.berndpruenster.netlayer.tor.Tor");
Field torControllerField = torClass.getDeclaredField("torController");
torControllerField.setAccessible(true);
TorController torController = (TorController)torControllerField.get(tor);
Class<?> torControllerClass = Class.forName("org.berndpruenster.netlayer.tor.TorController");
Field socketField = torControllerClass.getDeclaredField("socket");
socketField.setAccessible(true);
return (Socket)socketField.get(torController);
} catch(Exception e) {
log.error("Error retrieving Tor control socket", e);
}
}
return null;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.net;
public class TorStartupException extends TorServerException {
public TorStartupException(Throwable cause) {
super(cause);
}
public TorStartupException(String message) {
super(message);
}
public TorStartupException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -27,21 +27,15 @@ public class TorTcpOverTlsTransport extends TcpOverTlsTransport {
@Override
protected void createSocket() throws IOException {
TorTcpTransport torTcpTransport = new TorTcpTransport(server);
TorTcpTransport torTcpTransport = new TorTcpTransport(server) {
@Override
protected int getDefaultPort() {
return Protocol.SSL.getDefaultPort();
}
};
torTcpTransport.createSocket();
socket = torTcpTransport.socket;
try {
Field socketField = socket.getClass().getDeclaredField("socket");
socketField.setAccessible(true);
Socket innerSocket = (Socket)socketField.get(socket);
Field connectedField = innerSocket.getClass().getSuperclass().getDeclaredField("connected");
connectedField.setAccessible(true);
connectedField.set(innerSocket, true);
} catch(Exception e) {
log.error("Could not set socket connected status", e);
}
socket = sslSocketFactory.createSocket(socket, server.getHost(), server.getPortOrDefault(Protocol.SSL.getDefaultPort()), true);
startHandshake((SSLSocket)socket);
}
@@ -2,14 +2,12 @@ package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.AppServices;
import org.berndpruenster.netlayer.tor.*;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.Socket;
public class TorTcpTransport extends TcpTransport {
public static final String TOR_DIR_PREFIX = "tor";
public TorTcpTransport(HostAndPort server) {
super(server);
}
@@ -24,6 +22,7 @@ public class TorTcpTransport extends TcpTransport {
throw new IllegalStateException("Can't create Tor socket, Tor is not running");
}
socket = new TorSocket(server.getHost(), server.getPortOrDefault(Protocol.TCP.getDefaultPort()), "sparrow");
socket = new Socket(Tor.getDefault().getProxy());
socket.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(getDefaultPort())));
}
}
@@ -35,7 +35,7 @@ public class Cormorant {
}
public Server start() throws CormorantBitcoindException {
bitcoindClient = new BitcoindClient();
bitcoindClient = new BitcoindClient(useWallets);
bitcoindClient.initialize();
Thread importThread = new Thread(() -> {
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.google.common.collect.Sets;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
@@ -25,11 +26,14 @@ import com.sparrowwallet.sparrow.net.cormorant.electrum.ScriptHashStatus;
import com.sparrowwallet.sparrow.net.cormorant.index.Store;
import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@@ -60,6 +64,7 @@ public class BitcoindClient {
private Exception lastPollException;
private final boolean useWallets;
private boolean pruned;
private boolean legacyWalletExists;
@@ -76,7 +81,11 @@ public class BitcoindClient {
private final List<String> pruneWarnedDescriptors = new ArrayList<>();
public BitcoindClient() {
private final Map<String, MempoolEntry> mempoolEntries = new ConcurrentHashMap<>();
private MempoolEntriesState mempoolEntriesState = MempoolEntriesState.UNINITIALIZED;
private long timerTaskCount;
public BitcoindClient(boolean useWallets) {
BitcoindTransport bitcoindTransport;
Config config = Config.get();
@@ -87,6 +96,7 @@ public class BitcoindClient {
}
this.jsonRpcClient = new JsonRpcClient(bitcoindTransport);
this.useWallets = useWallets;
}
public void initialize() throws CormorantBitcoindException {
@@ -125,6 +135,9 @@ public class BitcoindClient {
}
ListWalletDirResult listWalletDirResult = getBitcoindService().listWalletDir();
if(listWalletDirResult == null) {
throw new RuntimeException("Wallet support must be enabled in Bitcoin Core");
}
boolean exists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(CORE_WALLET_NAME));
legacyWalletExists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(Bwt.DEFAULT_CORE_WALLET));
@@ -444,7 +457,7 @@ public class BitcoindClient {
private String getTransaction(String txid) {
try {
return getBitcoindService().getTransaction(txid, false).get("hex").toString();
return getBitcoindService().getTransaction(txid, true, false).get("hex").toString();
} catch(JsonRpcException e) {
return getBitcoindService().getRawTransaction(txid, false).toString();
}
@@ -513,6 +526,64 @@ public class BitcoindClient {
}
}
public void initializeMempoolEntries() {
mempoolEntriesState = MempoolEntriesState.INITIALIZING;
long start = System.currentTimeMillis();
Set<String> txids = getBitcoindService().getRawMempool();
long end = System.currentTimeMillis();
if(end - start < 1000) {
//Fast system, fetch all mempool data at once
mempoolEntries.putAll(getBitcoindService().getRawMempool(true));
} else {
//Slow system, fetch mempool entries one-by-one to avoid risking a node crash
for(String txid : txids) {
try {
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
mempoolEntries.put(txid, mempoolEntry);
} catch(JsonRpcException e) {
//ignore, probably tx has been removed from mempool
}
}
}
mempoolEntriesState = MempoolEntriesState.INITIALIZED;
}
public void updateMempoolEntries() {
Set<String> txids = getBitcoindService().getRawMempool();
Set<String> removed = new HashSet<>(Sets.difference(mempoolEntries.keySet(), txids));
mempoolEntries.keySet().removeAll(removed);
Set<String> added = Sets.difference(txids, mempoolEntries.keySet());
for(String txid : added) {
try {
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
mempoolEntries.put(txid, mempoolEntry);
} catch(JsonRpcException e) {
//ignore, probably tx has been removed from mempool
}
}
}
public Map<String, MempoolEntry> getMempoolEntries() {
return mempoolEntries;
}
public MempoolEntriesState getMempoolEntriesState() {
return mempoolEntriesState;
}
public InitializeMempoolEntriesService getInitializeMempoolEntriesService() {
return new InitializeMempoolEntriesService();
}
public boolean isUseWallets() {
return useWallets;
}
public Store getStore() {
return store;
}
@@ -539,7 +610,7 @@ public class BitcoindClient {
try {
if(syncing) {
BlockchainInfo blockchainInfo = getBitcoindService().getBlockchainInfo();
if(blockchainInfo.initialblockdownload()) {
if(blockchainInfo.initialblockdownload() && !isEmptyBlockchain(blockchainInfo)) {
int percent = blockchainInfo.getProgressPercent();
Date tipDate = blockchainInfo.getTip();
Platform.runLater(() -> EventManager.get().post(new CormorantSyncStatusEvent("Syncing" + (percent < 100 ? " (" + percent + "%)" : ""), percent, tipDate)));
@@ -563,6 +634,10 @@ public class BitcoindClient {
}
}
if(mempoolEntriesState == MempoolEntriesState.INITIALIZED && (++timerTaskCount+1) % 12 == 0) {
updateMempoolEntries();
}
ListSinceBlock listSinceBlock = getListSinceBlock(lastBlock);
String currentBlock = lastBlock;
updateStore(listSinceBlock);
@@ -588,7 +663,7 @@ public class BitcoindClient {
}
} catch(Exception e) {
lastPollException = e;
log.warn("Error polling Bitcoin Core: " + e.getMessage());
log.warn("Error polling Bitcoin Core", e);
if(syncing) {
syncingLock.lock();
@@ -615,9 +690,30 @@ public class BitcoindClient {
return scanningWallets;
}
private boolean isEmptyBlockchain(BlockchainInfo blockchainInfo) {
return blockchainInfo.blocks() == 0 && blockchainInfo.getProgressPercent() == 100;
}
private record ScanDate(Date rescanSince, Integer range, boolean forceRescan) {
public Object getTimestamp() {
return rescanSince == null ? "now" : rescanSince.getTime() / 1000;
}
}
public class InitializeMempoolEntriesService extends Service<Void> {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
initializeMempoolEntries();
return null;
}
};
}
}
public enum MempoolEntriesState {
UNINITIALIZED, INITIALIZING, INITIALIZED
}
}
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
import com.github.arteam.simplejsonrpc.client.JsonRpcParams;
import com.github.arteam.simplejsonrpc.client.ParamsType;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
@@ -7,8 +9,10 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import java.util.List;
import java.util.Map;
import java.util.Set;
@JsonRpcService
@JsonRpcParams(ParamsType.ARRAY)
public interface BitcoindClientService {
@JsonRpcMethod("uptime")
long uptime();
@@ -19,6 +23,9 @@ public interface BitcoindClientService {
@JsonRpcMethod("estimatesmartfee")
FeeInfo estimateSmartFee(@JsonRpcParam("conf_target") int blocks);
@JsonRpcMethod("getrawmempool")
Set<String> getRawMempool();
@JsonRpcMethod("getrawmempool")
Map<String, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
@@ -44,7 +51,7 @@ public interface BitcoindClientService {
Object getRawTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("gettransaction")
Map<String, Object> getTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("verbose") boolean verbose);
Map<String, Object> getTransaction(@JsonRpcParam("txid") String txid, @JsonRpcParam("include_watchonly") boolean includeWatchOnly, @JsonRpcParam("verbose") boolean verbose);
@JsonRpcMethod("getmempoolentry")
MempoolEntry getMempoolEntry(@JsonRpcParam("txid") String txid);
@@ -4,6 +4,7 @@ import com.github.arteam.simplejsonrpc.client.Transport;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.net.Protocol;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -15,6 +16,7 @@ import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Base64;
@@ -23,6 +25,7 @@ public class BitcoindTransport implements Transport {
private static final Logger log = LoggerFactory.getLogger(BitcoindTransport.class);
public static final String COOKIE_FILENAME = ".cookie";
private final Server bitcoindServer;
private URL bitcoindUrl;
private File cookieFile;
private Long cookieFileTimestamp;
@@ -39,8 +42,13 @@ public class BitcoindTransport implements Transport {
}
private BitcoindTransport(Server bitcoindServer, String bitcoindWallet) {
this.bitcoindServer = bitcoindServer;
try {
this.bitcoindUrl = new URL(bitcoindServer.getUrl() + "/wallet/" + bitcoindWallet);
String serverUrl = bitcoindServer.getUrl();
if(!bitcoindServer.getHostAndPort().hasPort()) {
serverUrl += ":" + Network.get().getDefaultPort();
}
this.bitcoindUrl = new URL(serverUrl + "/wallet/" + bitcoindWallet);
} catch(MalformedURLException e) {
log.error("Malformed Bitcoin Core RPC URL", e);
}
@@ -49,7 +57,7 @@ public class BitcoindTransport implements Transport {
@Override
public String pass(String request) throws IOException {
Proxy proxy = AppServices.getProxy();
HttpURLConnection connection = proxy == null ? (HttpURLConnection)bitcoindUrl.openConnection() : (HttpURLConnection)bitcoindUrl.openConnection(proxy);
HttpURLConnection connection = proxy != null && Protocol.isOnionAddress(bitcoindServer) ? (HttpURLConnection)bitcoindUrl.openConnection(proxy) : (HttpURLConnection)bitcoindUrl.openConnection();
if(connection instanceof HttpsURLConnection httpsURLConnection) {
SSLSocketFactory sslSocketFactory = getTrustAllSocketFactory();
@@ -68,7 +76,7 @@ public class BitcoindTransport implements Transport {
connection.setDoOutput(true);
log.trace("> " + request);
log.debug("> " + request);
try(OutputStream os = connection.getOutputStream()) {
byte[] jsonBytes = request.getBytes(StandardCharsets.UTF_8);
@@ -94,7 +102,7 @@ public class BitcoindTransport implements Transport {
}
String response = res.toString();
log.trace("< " + response);
log.debug("< " + response);
return response;
}
@@ -120,11 +128,11 @@ public class BitcoindTransport implements Transport {
}
private static File getCookieDir(File bitcoindDir) {
if(Network.get() == Network.TESTNET && !bitcoindDir.getName().contains("testnet")) {
if(Network.get() == Network.TESTNET && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "testnet3", COOKIE_FILENAME))) {
return new File(bitcoindDir, "testnet3");
} else if(Network.get() == Network.REGTEST && !bitcoindDir.getName().contains("regtest")) {
} else if(Network.get() == Network.REGTEST && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "regtest", COOKIE_FILENAME))) {
return new File(bitcoindDir, "regtest");
} else if(Network.get() == Network.SIGNET && !bitcoindDir.getName().contains("signet")) {
} else if(Network.get() == Network.SIGNET && Files.exists(Path.of(bitcoindDir.getAbsolutePath(), "signet", COOKIE_FILENAME))) {
return new File(bitcoindDir, "signet");
}
@@ -13,7 +13,7 @@ public record VerboseBlockHeader(String hash, int confirmations, int height, int
String bits, double difficulty, String chainwork, int nTx, String previousblockhash) {
public ElectrumBlockHeader getBlockHeader() {
BigInteger nBits = new BigInteger(bits, 16);
BlockHeader blockHeader = new BlockHeader(version, Sha256Hash.wrap(previousblockhash), Sha256Hash.wrap(merkleroot), null, time, nBits.longValue(), nonce);
BlockHeader blockHeader = new BlockHeader(version, previousblockhash == null ? Sha256Hash.ZERO_HASH : Sha256Hash.wrap(previousblockhash), Sha256Hash.wrap(merkleroot), null, time, nBits.longValue(), nonce);
return new ElectrumBlockHeader(height, Utils.bytesToHex(blockHeader.bitcoinSerialize()));
}
}
@@ -5,7 +5,10 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.MempoolEntriesInitializedEvent;
import com.sparrowwallet.sparrow.net.Version;
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.*;
@@ -60,9 +63,23 @@ public class ElectrumServerService {
}
@JsonRpcMethod("mempool.get_fee_histogram")
public List<List<Number>> getFeeHistogram() throws BitcoindIOException {
try {
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getBitcoindService().getRawMempool(true);
public List<List<Number>> getFeeHistogram() {
BitcoindClient.MempoolEntriesState mempoolEntriesState = bitcoindClient.getMempoolEntriesState();
if(mempoolEntriesState != BitcoindClient.MempoolEntriesState.INITIALIZED) {
if(bitcoindClient.isUseWallets() && mempoolEntriesState == BitcoindClient.MempoolEntriesState.UNINITIALIZED) {
BitcoindClient.InitializeMempoolEntriesService initializeMempoolEntriesService = bitcoindClient.getInitializeMempoolEntriesService();
initializeMempoolEntriesService.setOnSucceeded(successEvent -> {
EventManager.get().post(new MempoolEntriesInitializedEvent());
});
initializeMempoolEntriesService.setOnFailed(failedEvent -> {
log.error("Failed to initialize mempool entries", failedEvent.getSource().getException());
});
initializeMempoolEntriesService.start();
}
return Collections.emptyList();
} else {
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getMempoolEntries();
List<VsizeFeerate> vsizeFeerates = mempoolEntries.values().stream().map(entry -> new VsizeFeerate(entry.vsize(), entry.fees().base())).sorted().toList();
@@ -85,8 +102,6 @@ public class ElectrumServerService {
}
return histogram;
} catch(IllegalStateException e) {
throw new BitcoindIOException(e);
}
}
@@ -150,7 +165,7 @@ public class ElectrumServerService {
return bitcoindClient.getBitcoindService().getRawTransaction(tx_hash, true);
} catch(JsonRpcException e) {
try {
Map<String, Object> txInfo = bitcoindClient.getBitcoindService().getTransaction(tx_hash, true);
Map<String, Object> txInfo = bitcoindClient.getBitcoindService().getTransaction(tx_hash, true, true);
Object decoded = txInfo.get("decoded");
if(decoded instanceof Map<?, ?>) {
Map<String, Object> decodedMap = (Map<String, Object>)decoded;
@@ -172,7 +187,7 @@ public class ElectrumServerService {
}
} else {
try {
return bitcoindClient.getBitcoindService().getTransaction(tx_hash, false).get("hex");
return bitcoindClient.getBitcoindService().getTransaction(tx_hash, true, false).get("hex");
} catch(JsonRpcException e) {
try {
return bitcoindClient.getBitcoindService().getRawTransaction(tx_hash, false);
@@ -204,7 +219,9 @@ public class ElectrumServerService {
public VsizeFeerate(int vsize, double fee) {
this.vsize = vsize;
this.feerate = fee / vsize * 100000000;
double feeRate = fee / vsize * Transaction.SATOSHIS_PER_BITCOIN;
//Round down to 0.1 sats/vb precision
this.feerate = Math.floor(10 * feeRate) / 10;
}
@Override
@@ -66,7 +66,7 @@ public class Payjoin {
try {
String base64Psbt = psbt.getPublicCopy().toBase64String();
String appendQuery = "v=1";
String appendQuery = "v=1&minfeerate=" + AppServices.getMinimumRelayFeeRate();
int changeOutputIndex = getChangeOutputIndex();
long maxAdditionalFeeContribution = 0;
if(changeOutputIndex > -1) {
@@ -441,6 +441,7 @@ public class PayNymController {
if(!storage.isPersisted(addedWallet)) {
try {
storage.saveWallet(addedWallet);
EventManager.get().post(new NewChildWalletSavedEvent(storage, masterWallet, addedWallet));
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
@@ -33,7 +33,7 @@ public class PayNymService {
private final JavaHttpClientService httpClientService;
public PayNymService(HostAndPort torProxy) {
this.httpClientService = new JavaHttpClientService(torProxy);
this.httpClientService = new JavaHttpClientService(torProxy, 120000);
}
public Observable<Map<String, Object>> createPayNym(Wallet wallet) {
@@ -1,32 +1,44 @@
package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.TextfieldDialog;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Server;
import com.sparrowwallet.sparrow.net.BlockExplorer;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.ComboBox;
import javafx.util.StringConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.Currency;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class GeneralPreferencesController extends PreferencesDetailController {
private static final Logger log = LoggerFactory.getLogger(GeneralPreferencesController.class);
@FXML
private ComboBox<BitcoinUnit> bitcoinUnit;
private static final Server CUSTOM_BLOCK_EXPLORER = new Server("http://custom.block.explorer");
@FXML
private ComboBox<FeeRatesSource> feeRatesSource;
@FXML
private ComboBox<Server> blockExplorers;
@FXML
private ComboBox<Currency> fiatCurrency;
@@ -63,17 +75,6 @@ public class GeneralPreferencesController extends PreferencesDetailController {
@Override
public void initializeView(Config config) {
if(config.getBitcoinUnit() != null) {
bitcoinUnit.setValue(config.getBitcoinUnit());
} else {
bitcoinUnit.setValue(BitcoinUnit.AUTO);
}
bitcoinUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
config.setBitcoinUnit(newValue);
EventManager.get().post(new BitcoinUnitChangedEvent(newValue));
});
if(config.getFeeRatesSource() != null) {
feeRatesSource.setValue(config.getFeeRatesSource());
} else {
@@ -86,6 +87,62 @@ public class GeneralPreferencesController extends PreferencesDetailController {
EventManager.get().post(new FeeRatesSourceChangedEvent(newValue));
});
blockExplorers.setItems(getBlockExplorerList());
blockExplorers.setConverter(new StringConverter<>() {
@Override
public String toString(Server server) {
if(server == null || server == BlockExplorer.NONE.getServer()) {
return "None";
}
if(server == CUSTOM_BLOCK_EXPLORER) {
return "Custom...";
}
return server.getHost();
}
@Override
public Server fromString(String string) {
return null;
}
});
blockExplorers.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue == CUSTOM_BLOCK_EXPLORER) {
TextfieldDialog textfieldDialog = new TextfieldDialog();
textfieldDialog.setTitle("Enter Block Explorer URL");
textfieldDialog.setHeaderText("Enter the URL of the block explorer.\n\nIf present, the characters {0} will be replaced with the txid.\nFor example, https://localhost or https://localhost/tx/{0}\n");
textfieldDialog.getEditor().setPromptText("https://localhost");
Optional<String> optUrl = textfieldDialog.showAndWait();
if(optUrl.isPresent() && !optUrl.get().isEmpty()) {
try {
Server server = getBlockExplorer(optUrl.get());
config.setBlockExplorer(server);
Platform.runLater(() -> {
blockExplorers.getSelectionModel().select(-1);
blockExplorers.setItems(getBlockExplorerList());
blockExplorers.setValue(Config.get().getBlockExplorer());
});
} catch(Exception e) {
AppServices.showErrorDialog("Invalid URL", "The URL " + optUrl.get() + " is not valid.");
blockExplorers.setValue(oldValue);
}
} else {
blockExplorers.setValue(oldValue);
}
} else {
Config.get().setBlockExplorer(newValue);
}
}
});
if(config.getBlockExplorer() != null) {
blockExplorers.setValue(config.getBlockExplorer());
} else {
blockExplorers.getSelectionModel().select(0);
}
if(config.getExchangeSource() != null) {
exchangeSource.setValue(config.getExchangeSource());
} else {
@@ -134,6 +191,23 @@ public class GeneralPreferencesController extends PreferencesDetailController {
});
}
private static Server getBlockExplorer(String serverUrl) {
String url = serverUrl.trim();
if(url.endsWith("/")) {
url = url.substring(0, url.length() - 1);
}
return new Server(url);
}
private ObservableList<Server> getBlockExplorerList() {
List<Server> servers = Arrays.stream(BlockExplorer.values()).map(BlockExplorer::getServer).collect(Collectors.toList());
if(Config.get().getBlockExplorer() != null && !servers.contains(Config.get().getBlockExplorer())) {
servers.add(Config.get().getBlockExplorer());
}
servers.add(CUSTOM_BLOCK_EXPLORER);
return FXCollections.observableList(servers);
}
private void updateCurrencies(ExchangeSource exchangeSource) {
ExchangeSource.CurrenciesService currenciesService = new ExchangeSource.CurrenciesService(exchangeSource);
currenciesService.setOnSucceeded(event -> {
@@ -28,8 +28,6 @@ import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -243,6 +241,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
proxyHost.textProperty().addListener(getProxyListener(config));
proxyPort.textProperty().addListener(getProxyListener(config));
corePort.setPromptText("e.g. " + Network.get().getDefaultPort());
coreDataDirField.managedProperty().bind(coreDataDirField.visibleProperty());
coreUserPassField.managedProperty().bind(coreUserPassField.visibleProperty());
coreUserPassField.visibleProperty().bind(coreDataDirField.visibleProperty().not());
@@ -493,20 +492,6 @@ public class ServerPreferencesController extends PreferencesDetailController {
torService.cancel();
testResults.appendText("\nTor failed to start");
showConnectionFailure(workerStateEvent.getSource().getException());
Throwable exception = workerStateEvent.getSource().getException();
if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER &&
exception instanceof TorServerAlreadyBoundException &&
useProxyOriginal == null && !useProxy.isSelected() &&
(proxyHost.getText().isEmpty() || proxyHost.getText().equals("localhost") || proxyHost.getText().equals("127.0.0.1")) &&
(proxyPort.getText().isEmpty() || proxyPort.getText().equals("9050"))) {
useProxy.setSelected(true);
proxyHost.setText("localhost");
proxyPort.setText("9050");
useProxyOriginal = false;
testResults.appendText("\n\nAssuming Tor proxy is running on port 9050 and trying again...");
startElectrumConnection();
}
});
torService.start();
@@ -657,8 +642,6 @@ public class ServerPreferencesController extends PreferencesDetailController {
reason = tlsServerException.getMessage() + "\n\n" + reason;
} else if(exception instanceof ProxyServerException) {
reason += ". Check if the proxy server is running.";
} else if(exception instanceof TorServerAlreadyBoundException) {
reason += "\nIs a Tor proxy already running on port " + TorService.PROXY_PORT + "?";
} else if(reason != null && (reason.contains("Check if Bitcoin Core is running") || reason.contains("Could not connect to Bitcoin Core RPC"))) {
reason += "\n\nSee https://sparrowwallet.com/docs/connect-node.html";
} else if(reason != null && (reason.startsWith("Cannot connect to hidden service"))) {
@@ -734,6 +717,10 @@ public class ServerPreferencesController extends PreferencesDetailController {
private ChangeListener<String> getBitcoinCoreListener(Config config) {
return (observable, oldValue, newValue) -> {
Server existingServer = config.getRecentCoreServers().stream().filter(server -> coreHost.getText().equals(server.getAlias())).findFirst().orElse(null);
if(existingServer != null && !existingServer.portEquals(corePort.getText())) {
coreHost.setText(existingServer.getHost());
existingServer = null;
}
coreHost.setLeft(existingServer == null ? null : getGlyph(FontAwesome5.Glyph.TAG, null));
setCoreServerInConfig(config);
};
@@ -769,6 +756,10 @@ public class ServerPreferencesController extends PreferencesDetailController {
private ChangeListener<String> getElectrumServerListener(Config config) {
return (observable, oldValue, newValue) -> {
Server existingServer = config.getRecentElectrumServers().stream().filter(server -> electrumHost.getText().equals(server.getAlias())).findFirst().orElse(null);
if(existingServer != null && !existingServer.portEquals(electrumPort.getText())) {
electrumHost.setText(existingServer.getHost());
existingServer = null;
}
electrumHost.setLeft(existingServer == null ? null : getGlyph(FontAwesome5.Glyph.TAG, null));
setElectrumServerInConfig(config);
};
@@ -8,13 +8,9 @@ import com.samourai.wallet.cahoots.Cahoots;
import com.samourai.wallet.cahoots.CahootsType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Status;
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.control.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.paynym.PayNymDialog;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;

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