mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 03:56:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde8fef35e | ||
|
|
87e7a87e5e | ||
|
|
85eb4df7e9 | ||
|
|
bb32a1e7b1 | ||
|
|
f590794589 | ||
|
|
1e3ce7eb88 | ||
|
|
ef3e2ed695 | ||
|
|
74c3370277 | ||
|
|
170e7c0abf | ||
|
|
e0486ff4a9 | ||
|
|
1d17384152 | ||
|
|
aec26d512b | ||
|
|
9e5a6e83d1 | ||
|
|
eecb90e9b2 | ||
|
|
b3516063b2 | ||
|
|
1d560d6aa6 | ||
|
|
d128401a09 | ||
|
|
9391a397da | ||
|
|
a5312374a8 | ||
|
|
c81c42a87c | ||
|
|
d84ade5b7d | ||
|
|
323d29e34a | ||
|
|
95cb8c4b2c | ||
|
|
2712555c72 | ||
|
|
72066395d6 | ||
|
|
7b3e5f37b9 | ||
|
|
870a468584 | ||
|
|
0340cba441 | ||
|
|
91f845cbbf | ||
|
|
d0f21eafd1 | ||
|
|
36c2181a7f | ||
|
|
218e751333 | ||
|
|
995d2c5e4e | ||
|
|
0d7ae74f0f | ||
|
|
e6eea67c4b | ||
|
|
ea3e0ca34a | ||
|
|
3fedd8eb43 | ||
|
|
30408af719 | ||
|
|
910a400b18 | ||
|
|
f4ac18c3e1 | ||
|
|
cb06e1aaf7 | ||
|
|
d881e47ec9 | ||
|
|
dbafefb940 | ||
|
|
ee6589991d | ||
|
|
24578dcf88 | ||
|
|
ddae1a12d8 | ||
|
|
1c9b6c3eef | ||
|
|
158ecd4ab1 | ||
|
|
b6bcdef712 | ||
|
|
6eefd3f182 | ||
|
|
9280504f70 | ||
|
|
a6a671f687 | ||
|
|
4e3e8b7cc4 | ||
|
|
2b8fc3900a | ||
|
|
cff731dec7 | ||
|
|
c9c0c35964 | ||
|
|
afed5c65f5 | ||
|
|
0724c38582 | ||
|
|
31539a27ac | ||
|
|
201900aa0e | ||
|
|
3dcbe34485 | ||
|
|
31842cc0f2 | ||
|
|
f85349bd36 | ||
|
|
a18c24e19f | ||
|
|
e23c1b3872 | ||
|
|
3c2ef43526 | ||
|
|
c81aae0c6a | ||
|
|
ff1a9e8a52 | ||
|
|
90bfa47046 | ||
|
|
6383b8b46f | ||
|
|
76dc294748 | ||
|
|
0bc1dd96ed | ||
|
|
73d2d3cbbc | ||
|
|
d5fdd6881c | ||
|
|
b1bc25ba04 | ||
|
|
2c1204c247 | ||
|
|
05a1fd8e8d | ||
|
|
97f21394a7 | ||
|
|
c57a445046 | ||
|
|
fb981f1548 | ||
|
|
407dde2703 | ||
|
|
f175139fd3 | ||
|
|
bebd7eebe5 | ||
|
|
e01f6b42b1 | ||
|
|
bd1c6c076e | ||
|
|
46b1bd2fd2 | ||
|
|
2d7c5dcec7 | ||
|
|
93bcf6cef9 | ||
|
|
4b6a03ef56 | ||
|
|
7ff4230e13 | ||
|
|
7d7967ec00 | ||
|
|
b0883f034b | ||
|
|
f88628c469 | ||
|
|
ac7a964edf | ||
|
|
7bb22419df | ||
|
|
c443bc78d3 | ||
|
|
a5519537c5 | ||
|
|
ef67d1f33b | ||
|
|
31bd64f821 | ||
|
|
4c408ac7b1 | ||
|
|
30a9c1208a | ||
|
|
96fd824a3e | ||
|
|
0a469a380b | ||
|
|
5e3f31de30 | ||
|
|
af9fb8694e | ||
|
|
ebfdfc0c9f | ||
|
|
c9d6bb350d | ||
|
|
f980516462 | ||
|
|
795892f7c9 | ||
|
|
9576581d89 | ||
|
|
296223130e | ||
|
|
87e2da0e01 | ||
|
|
9156ea1114 | ||
|
|
b8fc2fd59e | ||
|
|
ef9723ed44 | ||
|
|
5105b503ea | ||
|
|
9bcb34e7d1 | ||
|
|
185a17edce | ||
|
|
f8fa7f4cf2 | ||
|
|
bcbb531414 | ||
|
|
2deab05c45 | ||
|
|
c7923300c6 | ||
|
|
c4651025be | ||
|
|
2897f88c8b | ||
|
|
ee20a6980b | ||
|
|
721c446fa8 | ||
|
|
7c43ee7208 | ||
|
|
c6ea37e081 | ||
|
|
171bf24133 | ||
|
|
3242f00812 | ||
|
|
15500b6535 | ||
|
|
700c880b92 | ||
|
|
7f2c07c918 | ||
|
|
0745d21761 | ||
|
|
50aa9b4dcb | ||
|
|
fe50cb845e | ||
|
|
f4b9807285 | ||
|
|
f534beb624 | ||
|
|
719cfaa906 | ||
|
|
98d9a6882b | ||
|
|
dbbeaf67b6 | ||
|
|
2a542bb8b9 | ||
|
|
29b630f6bf | ||
|
|
3aa00076c6 | ||
|
|
3cf99961d3 | ||
|
|
742727d6f2 | ||
|
|
5d99eee89a | ||
|
|
b52be27a99 | ||
|
|
e68f177e4a | ||
|
|
e0a6626650 | ||
|
|
1d8888bb14 | ||
|
|
d0958b7936 | ||
|
|
d9d316a627 | ||
|
|
0270910b74 | ||
|
|
432e0642ca | ||
|
|
c7ab8e4601 | ||
|
|
1a46f8a643 | ||
|
|
04145bde74 | ||
|
|
483e4c8f38 | ||
|
|
fe4468d49d | ||
|
|
a4e9ef989d | ||
|
|
4ed8550f1d | ||
|
|
4bec71e7c4 | ||
|
|
961fd94dd6 | ||
|
|
7915bbfa47 | ||
|
|
49e70e8e9b | ||
|
|
6063b02113 | ||
|
|
faa5a11c94 | ||
|
|
acab50cdcd | ||
|
|
4d7d897e06 | ||
|
|
af532e7fc9 | ||
|
|
fd2b383dbc | ||
|
|
98b33e184e | ||
|
|
3bc7c7473a | ||
|
|
4f6981b869 | ||
|
|
258d46a253 | ||
|
|
40a3eb5d4f | ||
|
|
35965235f3 | ||
|
|
368b24ea3b | ||
|
|
107b5ba36c | ||
|
|
84978a3d5d | ||
|
|
dd3b980c36 | ||
|
|
b9a553abf2 | ||
|
|
48b3dbc353 | ||
|
|
97b4ed48db |
@@ -2,6 +2,9 @@ name: Package
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
+138
-39
@@ -7,7 +7,7 @@ plugins {
|
||||
id 'org.beryx.jlink' version '2.26.0'
|
||||
}
|
||||
|
||||
def sparrowVersion = '1.7.3'
|
||||
def sparrowVersion = '1.8.1'
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
def osName = os.getFamilyName()
|
||||
if(os.macOsX) {
|
||||
@@ -23,6 +23,19 @@ if(System.getProperty("os.arch") == "aarch64") {
|
||||
}
|
||||
def headless = "true".equals(System.getProperty("java.awt.headless"))
|
||||
|
||||
def vTor = '4.7.13-4'
|
||||
def vKmpTor = '1.4.3'
|
||||
def kmpOs = osName
|
||||
if(os.macOsX) {
|
||||
kmpOs = "macos"
|
||||
} else if(os.windows) {
|
||||
kmpOs = "mingw"
|
||||
}
|
||||
def kmpArch = "x64"
|
||||
if(System.getProperty("os.arch") == "aarch64") {
|
||||
kmpArch = "arm64"
|
||||
}
|
||||
|
||||
group "com.sparrowwallet"
|
||||
version "${sparrowVersion}"
|
||||
|
||||
@@ -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.35')
|
||||
implementation('com.sparrowwallet.nightjar:nightjar:0.2.39')
|
||||
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
|
||||
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
|
||||
implementation('org.apache.commons:commons-lang3:3.7')
|
||||
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
|
||||
implementation('com.github.librepdf:openpdf:1.3.27')
|
||||
implementation('com.github.librepdf:openpdf:1.3.30')
|
||||
implementation('com.googlecode.lanterna:lanterna:3.1.1')
|
||||
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.35.jar', 'com.sparrowwallet.nightjar', '0.2.35') {
|
||||
module('nightjar-0.2.39.jar', 'com.sparrowwallet.nightjar', '0.2.39') {
|
||||
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') {
|
||||
|
||||
@@ -82,7 +82,7 @@ sudo apt install -y rpm fakeroot binutils
|
||||
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
|
||||
|
||||
```shell
|
||||
GIT_TAG="1.7.3"
|
||||
GIT_TAG="1.8.0"
|
||||
```
|
||||
|
||||
The project can then be initially cloned as follows:
|
||||
|
||||
+1
-1
Submodule drongo updated: 0f78efc373...0bb5b75be5
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.7.3</string>
|
||||
<string>1.8.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
||||
@@ -107,6 +107,9 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private MenuItem exportWallet;
|
||||
|
||||
@FXML
|
||||
private MenuItem renameWallet;
|
||||
|
||||
@FXML
|
||||
private MenuItem deleteWallet;
|
||||
|
||||
@@ -166,6 +169,9 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private MenuItem lockAllWallets;
|
||||
|
||||
@FXML
|
||||
private MenuItem showWalletSummary;
|
||||
|
||||
@FXML
|
||||
private MenuItem searchWallet;
|
||||
|
||||
@@ -210,6 +216,8 @@ public class AppController implements Initializable {
|
||||
|
||||
private Timeline statusTimeline;
|
||||
|
||||
private SendToManyDialog sendToManyDialog;
|
||||
|
||||
private Tab previouslySelectedTab;
|
||||
|
||||
private boolean subTabsVisible;
|
||||
@@ -218,6 +226,8 @@ public class AppController implements Initializable {
|
||||
|
||||
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
|
||||
|
||||
private final Map<File, File> renamedWallets = new HashMap<>();
|
||||
|
||||
private final ChangeListener<Boolean> serverToggleOnlineListener = (observable, oldValue, newValue) -> {
|
||||
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
|
||||
};
|
||||
@@ -320,6 +330,8 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList()));
|
||||
});
|
||||
|
||||
registerShortcuts();
|
||||
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
if(unit == null) {
|
||||
unit = BitcoinUnit.AUTO;
|
||||
@@ -328,6 +340,9 @@ public class AppController implements Initializable {
|
||||
final BitcoinUnit selectedUnit = unit;
|
||||
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
|
||||
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
|
||||
Optional<Toggle> otherUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle ->
|
||||
(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(selectedUnit) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (selectedUnit == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData())))).findFirst();
|
||||
otherUnitToggle.ifPresent(toggle -> ((RadioMenuItem)toggle).setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN)));
|
||||
|
||||
UnitFormat format = Config.get().getUnitFormat();
|
||||
if(format == null) {
|
||||
@@ -370,9 +385,11 @@ public class AppController implements Initializable {
|
||||
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
|
||||
showPSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
|
||||
exportWallet.setDisable(true);
|
||||
renameWallet.disableProperty().bind(exportWallet.disableProperty());
|
||||
deleteWallet.disableProperty().bind(exportWallet.disableProperty());
|
||||
closeTab.setDisable(true);
|
||||
lockWallet.setDisable(true);
|
||||
showWalletSummary.disableProperty().bind(exportWallet.disableProperty());
|
||||
searchWallet.disableProperty().bind(exportWallet.disableProperty());
|
||||
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
|
||||
sendToMany.disableProperty().bind(exportWallet.disableProperty());
|
||||
@@ -397,6 +414,22 @@ public class AppController implements Initializable {
|
||||
setNetworkLabel();
|
||||
}
|
||||
|
||||
private void registerShortcuts() {
|
||||
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
|
||||
if(platform == org.controlsfx.tools.Platform.OSX) {
|
||||
tabs.getScene().addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
||||
if(event.isShortcutDown() && event.isAltDown() && (event.getCode() == KeyCode.LEFT || event.getCode() == KeyCode.RIGHT)) {
|
||||
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
|
||||
if(event.getCode() == KeyCode.LEFT && currentIndex > 0) {
|
||||
tabs.getSelectionModel().select(currentIndex - 1);
|
||||
} else if(event.getCode() == KeyCode.RIGHT && currentIndex < tabs.getTabs().size() - 1) {
|
||||
tabs.getSelectionModel().select(currentIndex + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void setPlatformApplicationMenu() {
|
||||
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
|
||||
if(platform == org.controlsfx.tools.Platform.OSX) {
|
||||
@@ -440,6 +473,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void showIntroduction(ActionEvent event) {
|
||||
WelcomeDialog welcomeDialog = new WelcomeDialog();
|
||||
welcomeDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
|
||||
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
|
||||
openPreferences(PreferenceGroup.SERVER);
|
||||
@@ -464,7 +498,17 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
public void submitBugReport(ActionEvent event) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
|
||||
ButtonType supportType = new ButtonType("Get Support", ButtonBar.ButtonData.LEFT);
|
||||
ButtonType bugType = new ButtonType("Submit Bug Report", ButtonBar.ButtonData.YES);
|
||||
Optional<ButtonType> optResponse = showWarningDialog("Submit Bug Report", "Please note that this facility is for bug reports and feature requests only. There is a community of Sparrow users who can assist with support requests.", supportType, bugType);
|
||||
|
||||
if(optResponse.isPresent()) {
|
||||
if(optResponse.get() == bugType) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
|
||||
} else {
|
||||
openSupport(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void showAbout(ActionEvent event) {
|
||||
@@ -517,7 +561,8 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
public void showInstallUdevMessage() {
|
||||
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules");
|
||||
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules", false);
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
dialog.setTitle("Install Udev Rules");
|
||||
dialog.getDialogPane().setHeaderText("Installing udev rules ensures devices can connect over USB.\nThis command requires root privileges.\nOpen a shell and enter the following:");
|
||||
dialog.showAndWait();
|
||||
@@ -535,9 +580,11 @@ public class AppController implements Initializable {
|
||||
);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if (file != null) {
|
||||
openTransactionFile(file);
|
||||
List<File> files = fileChooser.showOpenMultipleDialog(window);
|
||||
if(files != null) {
|
||||
for(File file : files) {
|
||||
openTransactionFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +633,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void openTransactionFromText(ActionEvent event) {
|
||||
TextAreaDialog dialog = new TextAreaDialog();
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
dialog.setTitle("Open from text");
|
||||
dialog.getDialogPane().setHeaderText("Paste a transaction or PSBT:");
|
||||
Optional<String> text = dialog.showAndWait();
|
||||
@@ -604,6 +652,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void openTransactionFromId(ActionEvent event) {
|
||||
TransactionIdDialog dialog = new TransactionIdDialog();
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Sha256Hash> optionalTxId = dialog.showAndWait();
|
||||
if(optionalTxId.isPresent()) {
|
||||
Sha256Hash txId = optionalTxId.get();
|
||||
@@ -631,6 +680,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void openTransactionFromQR(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
@@ -694,6 +744,7 @@ public class AppController implements Initializable {
|
||||
try {
|
||||
UR ur = UR.fromBytes(transaction.bitcoinSerialize());
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur);
|
||||
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
} catch(Exception e) {
|
||||
log.error("Error creating UR", e);
|
||||
@@ -753,7 +804,7 @@ public class AppController implements Initializable {
|
||||
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
|
||||
writer.flush();
|
||||
} else {
|
||||
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs));
|
||||
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs, true));
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.error("Error saving PSBT", e);
|
||||
@@ -792,6 +843,7 @@ public class AppController implements Initializable {
|
||||
|
||||
CryptoPSBT cryptoPSBT = new CryptoPSBT(transactionTabData.getPsbt().serialize());
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR());
|
||||
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
|
||||
qrDisplayDialog.show();
|
||||
}
|
||||
}
|
||||
@@ -836,6 +888,10 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
public void renameWallet(ActionEvent event) {
|
||||
renameWallet(getSelectedWalletForm());
|
||||
}
|
||||
|
||||
public void deleteWallet(ActionEvent event) {
|
||||
deleteWallet(getSelectedWalletForm());
|
||||
}
|
||||
@@ -953,6 +1009,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void newWallet(ActionEvent event) {
|
||||
WalletNameDialog dlg = new WalletNameDialog();
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = dlg.showAndWait();
|
||||
if(optNameAndBirthDate.isPresent()) {
|
||||
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
|
||||
@@ -974,9 +1031,11 @@ public class AppController implements Initializable {
|
||||
fileChooser.setInitialDirectory(Storage.getWalletsDir());
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if(file != null) {
|
||||
openWalletFile(file, forceSameWindow);
|
||||
List<File> files = fileChooser.showOpenMultipleDialog(window);
|
||||
if(files != null) {
|
||||
for(File file : files) {
|
||||
openWalletFile(file, forceSameWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1061,7 @@ public class AppController implements Initializable {
|
||||
loadWalletService.start();
|
||||
} else {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(storage.getWalletName(null), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
if(optionalPassword.isEmpty()) {
|
||||
return;
|
||||
@@ -1070,6 +1130,7 @@ public class AppController implements Initializable {
|
||||
public void importWallet(ActionEvent event) {
|
||||
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
|
||||
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Wallet> optionalWallet = dlg.showAndWait();
|
||||
if(optionalWallet.isPresent()) {
|
||||
Wallet wallet = optionalWallet.get();
|
||||
@@ -1107,6 +1168,7 @@ public class AppController implements Initializable {
|
||||
try(FileInputStream inputStream = new FileInputStream(file)) {
|
||||
if(importer.isEncrypted(file) && password == null) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
if(optionalPassword.isPresent()) {
|
||||
password = optionalPassword.get();
|
||||
@@ -1129,6 +1191,7 @@ public class AppController implements Initializable {
|
||||
|
||||
private void addImportedWallet(Wallet wallet) {
|
||||
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getBirthDate());
|
||||
nameDlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
|
||||
if(optNameAndBirthDate.isPresent()) {
|
||||
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
|
||||
@@ -1174,6 +1237,7 @@ public class AppController implements Initializable {
|
||||
|
||||
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
if(password.get().length() == 0) {
|
||||
@@ -1239,7 +1303,8 @@ public class AppController implements Initializable {
|
||||
public void exportWallet(ActionEvent event) {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm.getWallet());
|
||||
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Wallet> wallet = dlg.showAndWait();
|
||||
if(wallet.isPresent()) {
|
||||
//Successful export
|
||||
@@ -1257,6 +1322,7 @@ public class AppController implements Initializable {
|
||||
|
||||
private void openPreferences(PreferenceGroup preferenceGroup) {
|
||||
PreferencesDialog preferencesDialog = new PreferencesDialog(preferenceGroup);
|
||||
preferencesDialog.initOwner(rootStack.getScene().getWindow());
|
||||
preferencesDialog.showAndWait();
|
||||
configureSwitchServer();
|
||||
serverToggle.setDisable(!Config.get().hasServer());
|
||||
@@ -1267,8 +1333,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
Wallet wallet = selectedWalletForm.getWallet();
|
||||
if(wallet.getKeystores().size() == 1 &&
|
||||
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
if(wallet.getKeystores().size() == 1) {
|
||||
//Can sign and verify
|
||||
messageSignDialog = new MessageSignDialog(wallet);
|
||||
}
|
||||
@@ -1279,10 +1344,18 @@ public class AppController implements Initializable {
|
||||
messageSignDialog = new MessageSignDialog();
|
||||
}
|
||||
|
||||
messageSignDialog.initOwner(rootStack.getScene().getWindow());
|
||||
messageSignDialog.showAndWait();
|
||||
}
|
||||
|
||||
public void sendToMany(ActionEvent event) {
|
||||
if(sendToManyDialog != null) {
|
||||
Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow();
|
||||
stage.setAlwaysOnTop(true);
|
||||
stage.setAlwaysOnTop(false);
|
||||
return;
|
||||
}
|
||||
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
Wallet wallet = selectedWalletForm.getWallet();
|
||||
@@ -1291,11 +1364,14 @@ public class AppController implements Initializable {
|
||||
bitcoinUnit = wallet.getAutoUnit();
|
||||
}
|
||||
|
||||
SendToManyDialog sendToManyDialog = new SendToManyDialog(bitcoinUnit);
|
||||
sendToManyDialog = new SendToManyDialog(bitcoinUnit);
|
||||
sendToManyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
sendToManyDialog.initModality(Modality.NONE);
|
||||
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
|
||||
sendToManyDialog = null;
|
||||
optPayments.ifPresent(payments -> {
|
||||
if(!payments.isEmpty()) {
|
||||
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet())));
|
||||
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getSpendableUtxos().keySet())));
|
||||
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(wallet, payments)));
|
||||
}
|
||||
});
|
||||
@@ -1310,6 +1386,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
PrivateKeySweepDialog dialog = new PrivateKeySweepDialog(wallet);
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Transaction> optTransaction = dialog.showAndWait();
|
||||
optTransaction.ifPresent(transaction -> addTransactionTab(null, null, transaction));
|
||||
}
|
||||
@@ -1323,6 +1400,7 @@ public class AppController implements Initializable {
|
||||
if(wallet.isEncrypted()) {
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage storage = selectedWalletForm.getStorage();
|
||||
@@ -1336,6 +1414,7 @@ public class AppController implements Initializable {
|
||||
try {
|
||||
soroban.setHDWallet(copy);
|
||||
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
|
||||
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
if(Config.get().isSameAppMixing()) {
|
||||
counterpartyDialog.initModality(Modality.NONE);
|
||||
}
|
||||
@@ -1363,6 +1442,7 @@ public class AppController implements Initializable {
|
||||
} else {
|
||||
soroban.setHDWallet(wallet);
|
||||
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
|
||||
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
if(Config.get().isSameAppMixing()) {
|
||||
counterpartyDialog.initModality(Modality.NONE);
|
||||
}
|
||||
@@ -1370,6 +1450,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
} else {
|
||||
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
|
||||
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
if(Config.get().isSameAppMixing()) {
|
||||
counterpartyDialog.initModality(Modality.NONE);
|
||||
}
|
||||
@@ -1382,6 +1463,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId());
|
||||
payNymDialog.initOwner(rootStack.getScene().getWindow());
|
||||
payNymDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
@@ -1427,13 +1509,52 @@ public class AppController implements Initializable {
|
||||
TabPane subTabs = (TabPane) selectedTab.getContent();
|
||||
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
|
||||
if(!walletForms.isEmpty()) {
|
||||
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
|
||||
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
|
||||
if(optEntry.isPresent()) {
|
||||
Entry entry = optEntry.get();
|
||||
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
|
||||
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
|
||||
}
|
||||
searchWallets(walletForms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void searchAllWallets(ActionEvent event) {
|
||||
List<WalletForm> allWalletForms = new ArrayList<>();
|
||||
for(Tab tab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane)tab.getContent();
|
||||
allWalletForms.addAll(subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm())
|
||||
.filter(walletForm -> walletForm.getWallet().isValid() && !walletForm.isLocked()).collect(Collectors.toList()));
|
||||
}
|
||||
}
|
||||
|
||||
if(allWalletForms.isEmpty()) {
|
||||
showErrorDialog("No wallets", "There are no open and unlocked wallets to search.");
|
||||
} else {
|
||||
searchWallets(allWalletForms);
|
||||
}
|
||||
}
|
||||
|
||||
private void searchWallets(List<WalletForm> walletForms) {
|
||||
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
|
||||
searchWalletDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
|
||||
if(optEntry.isPresent()) {
|
||||
Entry entry = optEntry.get();
|
||||
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
|
||||
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
public void showWalletSummary(ActionEvent event) {
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
if(selectedTab != null) {
|
||||
TabData tabData = (TabData) selectedTab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane) selectedTab.getContent();
|
||||
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
|
||||
if(!walletForms.isEmpty()) {
|
||||
WalletSummaryDialog walletSummaryDialog = new WalletSummaryDialog(walletForms);
|
||||
walletSummaryDialog.initOwner(rootStack.getScene().getWindow());
|
||||
walletSummaryDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1524,6 +1645,11 @@ public class AppController implements Initializable {
|
||||
|
||||
tabs.getTabs().add(tab);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
|
||||
File oldWalletFile = renamedWallets.remove(storage.getWalletFile());
|
||||
if(oldWalletFile != null) {
|
||||
deleteStorage(new Storage(oldWalletFile), false);
|
||||
}
|
||||
} else {
|
||||
for(Tab walletTab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)walletTab.getUserData();
|
||||
@@ -1600,6 +1726,9 @@ public class AppController implements Initializable {
|
||||
subTabLabel.setGraphic(getSubTabGlyph(wallet));
|
||||
subTabLabel.setContentDisplay(ContentDisplay.TOP);
|
||||
subTabLabel.setAlignment(Pos.TOP_CENTER);
|
||||
if(isSubTabLabelTruncated(subTabLabel, label)) {
|
||||
subTabLabel.setTooltip(new Tooltip(label));
|
||||
}
|
||||
subTab.setGraphic(subTabLabel);
|
||||
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
|
||||
subTab.setContent(walletLoader.load());
|
||||
@@ -1853,6 +1982,7 @@ public class AppController implements Initializable {
|
||||
tabs.getTabs().add(index + 1, tab);
|
||||
tabs.getTabs().addListener(tabsChangeListener);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
|
||||
});
|
||||
MenuItem moveLeft = new MenuItem("Move Left");
|
||||
moveLeft.setOnAction(event -> {
|
||||
@@ -1862,6 +1992,7 @@ public class AppController implements Initializable {
|
||||
tabs.getTabs().add(index - 1, tab);
|
||||
tabs.getTabs().addListener(tabsChangeListener);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
|
||||
});
|
||||
contextMenu.getItems().addAll(moveRight, moveLeft);
|
||||
|
||||
@@ -1944,12 +2075,38 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
private void renameWallet(WalletForm selectedWalletForm) {
|
||||
WalletNameDialog walletNameDialog = new WalletNameDialog(selectedWalletForm.getMasterWallet().getName(), false, null, true);
|
||||
walletNameDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<WalletNameDialog.NameAndBirthDate> optName = walletNameDialog.showAndWait();
|
||||
if(optName.isPresent()) {
|
||||
File walletFile = Storage.getWalletFile(optName.get().getName() + "." + PersistenceType.DB.getExtension());
|
||||
if(walletFile.exists()) {
|
||||
showErrorDialog("Error renaming wallet", "Wallet file " + walletFile.getAbsolutePath() + " already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
Storage.CopyWalletService copyWalletService = new Storage.CopyWalletService(selectedWalletForm.getWallet(), walletFile);
|
||||
copyWalletService.setOnSucceeded(event -> {
|
||||
renamedWallets.put(walletFile, selectedWalletForm.getStorage().getWalletFile());
|
||||
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
|
||||
openWalletFile(walletFile, true);
|
||||
});
|
||||
copyWalletService.setOnFailed(event -> {
|
||||
log.error("Error renaming wallet", event.getSource().getException());
|
||||
showErrorDialog("Error renaming wallet", event.getSource().getException().getMessage());
|
||||
});
|
||||
copyWalletService.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteWallet(WalletForm selectedWalletForm) {
|
||||
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete " + selectedWalletForm.getWallet().getMasterName() + "?", "The wallet file and any backups will be deleted. Are you sure?", ButtonType.NO, ButtonType.YES);
|
||||
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
|
||||
Storage storage = selectedWalletForm.getStorage();
|
||||
if(selectedWalletForm.getMasterWallet().isEncrypted()) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(selectedWalletForm.getWallet().getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
|
||||
@@ -1959,7 +2116,7 @@ public class AppController implements Initializable {
|
||||
|
||||
try {
|
||||
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
|
||||
deleteStorage(storage);
|
||||
deleteStorage(storage, true);
|
||||
} finally {
|
||||
encryptionFullKey.clear();
|
||||
password.get().clear();
|
||||
@@ -1981,15 +2138,15 @@ public class AppController implements Initializable {
|
||||
}
|
||||
} else {
|
||||
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
|
||||
deleteStorage(storage);
|
||||
deleteStorage(storage, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStorage(Storage storage) {
|
||||
private void deleteStorage(Storage storage, boolean deleteBackups) {
|
||||
if(storage.isClosed()) {
|
||||
Platform.runLater(() -> {
|
||||
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage);
|
||||
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage, deleteBackups);
|
||||
deleteWalletService.setDelay(Duration.seconds(3));
|
||||
deleteWalletService.setPeriod(Duration.hours(1));
|
||||
deleteWalletService.setOnSucceeded(event -> {
|
||||
@@ -2005,7 +2162,7 @@ public class AppController implements Initializable {
|
||||
deleteWalletService.start();
|
||||
});
|
||||
} else {
|
||||
Platform.runLater(() -> deleteStorage(storage));
|
||||
Platform.runLater(() -> deleteStorage(storage, deleteBackups));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2017,10 +2174,16 @@ public class AppController implements Initializable {
|
||||
rename.setOnAction(event -> {
|
||||
Label subTabLabel = (Label)subTab.getGraphic();
|
||||
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(subTabLabel.getText());
|
||||
walletLabelDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<String> optLabel = walletLabelDialog.showAndWait();
|
||||
if(optLabel.isPresent()) {
|
||||
String label = optLabel.get();
|
||||
subTabLabel.setText(label);
|
||||
if(isSubTabLabelTruncated(subTabLabel, label)) {
|
||||
subTabLabel.setTooltip(new Tooltip(label));
|
||||
} else {
|
||||
subTabLabel.setTooltip(null);
|
||||
}
|
||||
|
||||
Wallet renamedWallet = AppServices.get().getWallet(walletId);
|
||||
renamedWallet.setLabel(label);
|
||||
@@ -2045,9 +2208,18 @@ public class AppController implements Initializable {
|
||||
contextMenu.getItems().add(delete);
|
||||
}
|
||||
|
||||
contextMenu.setOnShowing(event -> {
|
||||
Wallet renameWallet = AppServices.get().getWallet(walletId);
|
||||
rename.setDisable(!renameWallet.isValid());
|
||||
});
|
||||
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
private boolean isSubTabLabelTruncated(Label subTabLabel, String label) {
|
||||
return TextUtils.computeTextWidth(subTabLabel.getFont(), label, 0.0D) > (90-6);
|
||||
}
|
||||
|
||||
private void configureSwitchServer() {
|
||||
switchServer.getItems().clear();
|
||||
|
||||
@@ -2797,6 +2969,14 @@ public class AppController implements Initializable {
|
||||
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
|
||||
Optional<Toggle> selectedToggle = bitcoinUnit.getToggles().stream().filter(toggle -> event.getBitcoinUnit().equals(toggle.getUserData())).findFirst();
|
||||
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
|
||||
bitcoinUnit.getToggles().forEach(toggle -> {
|
||||
RadioMenuItem menuItem = (RadioMenuItem)toggle;
|
||||
if(List.of(BitcoinUnit.AUTO, BitcoinUnit.SATOSHIS).contains(event.getBitcoinUnit()) && BitcoinUnit.BTC.equals(toggle.getUserData()) || (event.getBitcoinUnit() == BitcoinUnit.BTC && BitcoinUnit.SATOSHIS.equals(toggle.getUserData()))) {
|
||||
menuItem.setAccelerator(new KeyCodeCombination(KeyCode.B, KeyCombination.SHORTCUT_DOWN));
|
||||
} else {
|
||||
menuItem.setAccelerator(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
|
||||
@@ -24,7 +24,6 @@ import com.sparrowwallet.sparrow.control.TrayManager;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.net.*;
|
||||
import com.sparrowwallet.sparrow.paynym.PayNymService;
|
||||
import com.sparrowwallet.sparrow.soroban.SorobanServices;
|
||||
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
|
||||
import javafx.application.Application;
|
||||
@@ -50,7 +49,6 @@ import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.Window;
|
||||
import javafx.util.Duration;
|
||||
import org.berndpruenster.netlayer.tor.Tor;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -64,6 +62,7 @@ import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
@@ -84,8 +83,10 @@ public class AppServices {
|
||||
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
|
||||
|
||||
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
|
||||
public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);
|
||||
public static final List<Long> LONG_FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L, 2048L, 4096L, 8192L);
|
||||
public static final List<Long> FEE_RATES_RANGE = LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
|
||||
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
|
||||
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
|
||||
|
||||
private static AppServices INSTANCE;
|
||||
|
||||
@@ -95,7 +96,7 @@ public class AppServices {
|
||||
|
||||
private InteractionServices interactionServices;
|
||||
|
||||
private static PayNymService payNymService;
|
||||
private static HttpClientService httpClientService;
|
||||
|
||||
private final Application application;
|
||||
|
||||
@@ -246,13 +247,13 @@ public class AppServices {
|
||||
versionCheckService.cancel();
|
||||
}
|
||||
|
||||
if(payNymService != null) {
|
||||
PayNymService.ShutdownService shutdownService = new PayNymService.ShutdownService(payNymService);
|
||||
if(httpClientService != null) {
|
||||
HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
|
||||
shutdownService.start();
|
||||
}
|
||||
|
||||
if(Tor.getDefault() != null) {
|
||||
Tor.getDefault().shutdown();
|
||||
Tor.getDefault().getTorManager().destroy(true, success -> {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,23 +415,6 @@ public class AppServices {
|
||||
EventManager.get().post(new TorReadyStatusEvent());
|
||||
});
|
||||
torService.setOnFailed(workerStateEvent -> {
|
||||
Throwable exception = workerStateEvent.getSource().getException();
|
||||
if(exception instanceof TorServerAlreadyBoundException) {
|
||||
String proxyServer = Config.get().getProxyServer();
|
||||
if(proxyServer == null || proxyServer.equals("")) {
|
||||
proxyServer = "localhost:9050";
|
||||
Config.get().setProxyServer(proxyServer);
|
||||
}
|
||||
|
||||
if(proxyServer.equals("localhost:9050") || proxyServer.equals("127.0.0.1:9050")) {
|
||||
Config.get().setUseProxy(true);
|
||||
torService.cancel();
|
||||
restartServices();
|
||||
EventManager.get().post(new TorExternalStatusEvent());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
|
||||
});
|
||||
|
||||
@@ -490,8 +474,7 @@ public class AppServices {
|
||||
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
|
||||
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
|
||||
} else if(AppServices.isTorRunning()) {
|
||||
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
|
||||
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
|
||||
proxy = Tor.getDefault().getProxy();
|
||||
}
|
||||
|
||||
//Setting new proxy authentication credentials will force a new Tor circuit to be created
|
||||
@@ -530,23 +513,23 @@ public class AppServices {
|
||||
return get().interactionServices;
|
||||
}
|
||||
|
||||
public static PayNymService getPayNymService() {
|
||||
if(payNymService == null) {
|
||||
public static HttpClientService getHttpClientService() {
|
||||
if(httpClientService == null) {
|
||||
HostAndPort torProxy = getTorProxy();
|
||||
payNymService = new PayNymService(torProxy);
|
||||
httpClientService = new HttpClientService(torProxy);
|
||||
} else {
|
||||
HostAndPort torProxy = getTorProxy();
|
||||
if(!Objects.equals(payNymService.getTorProxy(), torProxy)) {
|
||||
payNymService.setTorProxy(getTorProxy());
|
||||
if(!Objects.equals(httpClientService.getTorProxy(), torProxy)) {
|
||||
httpClientService.setTorProxy(getTorProxy());
|
||||
}
|
||||
}
|
||||
|
||||
return payNymService;
|
||||
return httpClientService;
|
||||
}
|
||||
|
||||
public static HostAndPort getTorProxy() {
|
||||
return AppServices.isTorRunning() ?
|
||||
HostAndPort.fromParts("localhost", TorService.PROXY_PORT) :
|
||||
Tor.getDefault().getProxyHostAndPort() :
|
||||
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer()));
|
||||
}
|
||||
|
||||
@@ -582,8 +565,17 @@ public class AppServices {
|
||||
return windowIcon;
|
||||
}
|
||||
|
||||
public static boolean isReducedWindowHeight() {
|
||||
Window activeWindow = getActiveWindow();
|
||||
return (activeWindow != null && activeWindow.getHeight() < getReducedWindowHeight());
|
||||
}
|
||||
|
||||
public static boolean isReducedWindowHeight(Node node) {
|
||||
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
|
||||
return (node.getScene() != null && node.getScene().getWindow().getHeight() < getReducedWindowHeight());
|
||||
}
|
||||
|
||||
private static double getReducedWindowHeight() {
|
||||
return org.controlsfx.tools.Platform.getCurrent() != org.controlsfx.tools.Platform.OSX ? 802d : 768d; //Check for menu bar of ~34px
|
||||
}
|
||||
|
||||
public Application getApplication() {
|
||||
@@ -670,15 +662,19 @@ public class AppServices {
|
||||
|
||||
public static Double getDefaultFeeRate() {
|
||||
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
|
||||
return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget);
|
||||
return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget);
|
||||
}
|
||||
|
||||
public static Double getMinimumFeeRate() {
|
||||
Optional<Double> optMinFeeRate = getTargetBlockFeeRates().values().stream().min(Double::compareTo);
|
||||
Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE);
|
||||
Double minRate = optMinFeeRate.orElse(getFallbackFeeRate());
|
||||
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
|
||||
}
|
||||
|
||||
public static double getFallbackFeeRate() {
|
||||
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
|
||||
}
|
||||
|
||||
public static Map<Integer, Double> getTargetBlockFeeRates() {
|
||||
return targetBlockFeeRates;
|
||||
}
|
||||
@@ -688,6 +684,10 @@ public class AppServices {
|
||||
}
|
||||
|
||||
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) {
|
||||
if(rateSizes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES);
|
||||
if(mempoolHistogram.isEmpty()) {
|
||||
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
|
||||
@@ -697,6 +697,12 @@ public class AppServices {
|
||||
|
||||
Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
|
||||
mempoolHistogram.keySet().removeIf(date -> date.before(yesterday));
|
||||
|
||||
ZonedDateTime twoHoursAgo = LocalDateTime.now().minusHours(2).atZone(ZoneId.systemDefault());
|
||||
mempoolHistogram.keySet().removeIf(date -> {
|
||||
ZonedDateTime dateTime = date.toInstant().atZone(ZoneId.systemDefault());
|
||||
return dateTime.isBefore(twoHoursAgo) && (dateTime.getMinute() % 10 != 0);
|
||||
});
|
||||
}
|
||||
|
||||
public static Double getMinimumRelayFeeRate() {
|
||||
@@ -916,7 +922,7 @@ public class AppServices {
|
||||
|
||||
if(wallet != null) {
|
||||
final Wallet sendingWallet = wallet;
|
||||
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true));
|
||||
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getSpendableUtxos().keySet()), true));
|
||||
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
@@ -956,6 +962,7 @@ public class AppServices {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(getActiveWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
|
||||
@@ -1018,6 +1025,7 @@ public class AppServices {
|
||||
wallet = wallets.iterator().next();
|
||||
} else {
|
||||
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
|
||||
walletChoiceDialog.initOwner(getActiveWindow());
|
||||
walletChoiceDialog.setTitle("Choose Wallet");
|
||||
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
|
||||
Image image = new Image("/image/sparrow-small.png");
|
||||
@@ -1041,8 +1049,6 @@ public class AppServices {
|
||||
public void newConnection(ConnectionEvent event) {
|
||||
currentBlockHeight = event.getBlockHeight();
|
||||
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
|
||||
targetBlockFeeRates = event.getTargetBlockFeeRates();
|
||||
addMempoolRateSizes(event.getMempoolRateSizes());
|
||||
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
|
||||
latestBlockHeader = event.getBlockHeader();
|
||||
Config.get().addRecentServer();
|
||||
@@ -1065,6 +1071,10 @@ public class AppServices {
|
||||
@Subscribe
|
||||
public void feesUpdated(FeeRatesUpdatedEvent event) {
|
||||
targetBlockFeeRates = event.getTargetBlockFeeRates();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
|
||||
addMempoolRateSizes(event.getMempoolRateSizes());
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen;
|
||||
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
|
||||
import static com.sparrowwallet.sparrow.AppServices.*;
|
||||
|
||||
public class DefaultInteractionServices implements InteractionServices {
|
||||
@Override
|
||||
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
|
||||
Alert alert = new Alert(alertType, content, buttons);
|
||||
alert.initOwner(getActiveWindow());
|
||||
setStageIcon(alert.getDialogPane().getScene().getWindow());
|
||||
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
alert.setTitle(title);
|
||||
@@ -53,6 +53,8 @@ public class DefaultInteractionServices implements InteractionServices {
|
||||
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
|
||||
}
|
||||
|
||||
alert.setResizable(true);
|
||||
|
||||
moveToActiveWindowScreen(alert);
|
||||
return alert.showAndWait();
|
||||
}
|
||||
@@ -60,6 +62,7 @@ public class DefaultInteractionServices implements InteractionServices {
|
||||
@Override
|
||||
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
|
||||
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
|
||||
passphraseDialog.initOwner(getActiveWindow());
|
||||
return passphraseDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ public class SparrowDesktop extends Application {
|
||||
GlyphFontRegistry.register(new FontAwesome5());
|
||||
GlyphFontRegistry.register(new FontAwesome5Brands());
|
||||
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
|
||||
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
|
||||
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
|
||||
|
||||
AppServices.initialize(this);
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.*;
|
||||
public class SparrowWallet {
|
||||
public static final String APP_ID = "com.sparrowwallet.sparrow";
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "1.7.3";
|
||||
public static final String APP_VERSION = "1.8.1";
|
||||
public static final String APP_VERSION_SUFFIX = "";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.LogHandler;
|
||||
import com.sparrowwallet.sparrow.event.TorStatusEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.event.Level;
|
||||
|
||||
public class TorLogHandler implements LogHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(TorLogHandler.class);
|
||||
|
||||
@Override
|
||||
public void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData) {
|
||||
log.debug(message);
|
||||
EventManager.get().post(new TorStatusEvent(message));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public enum UnitFormat {
|
||||
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
|
||||
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
|
||||
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
|
||||
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
|
||||
|
||||
public DecimalFormat getBtcFormat() {
|
||||
btcFormat.setMaximumFractionDigits(8);
|
||||
@@ -30,6 +31,10 @@ public enum UnitFormat {
|
||||
return currencyFormat;
|
||||
}
|
||||
|
||||
public DecimalFormat getTableCurrencyFormat() {
|
||||
return tableCurrencyFormat;
|
||||
}
|
||||
|
||||
public DecimalFormatSymbols getDecimalFormatSymbols() {
|
||||
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
|
||||
symbols.setDecimalSeparator('.');
|
||||
@@ -42,6 +47,7 @@ public enum UnitFormat {
|
||||
private final DecimalFormat satsFormat = new DecimalFormat("#,##0", getDecimalFormatSymbols());
|
||||
private final DecimalFormat tableBtcFormat = new DecimalFormat("0.00000000", getDecimalFormatSymbols());
|
||||
private final DecimalFormat currencyFormat = new DecimalFormat("#,##0.00", getDecimalFormatSymbols());
|
||||
private final DecimalFormat tableCurrencyFormat = new DecimalFormat("0.00", getDecimalFormatSymbols());
|
||||
|
||||
public DecimalFormat getBtcFormat() {
|
||||
btcFormat.setMaximumFractionDigits(8);
|
||||
@@ -60,6 +66,10 @@ public enum UnitFormat {
|
||||
return currencyFormat;
|
||||
}
|
||||
|
||||
public DecimalFormat getTableCurrencyFormat() {
|
||||
return tableCurrencyFormat;
|
||||
}
|
||||
|
||||
public DecimalFormatSymbols getDecimalFormatSymbols() {
|
||||
DecimalFormatSymbols symbols = new DecimalFormatSymbols();
|
||||
symbols.setDecimalSeparator(',');
|
||||
@@ -78,6 +88,8 @@ public enum UnitFormat {
|
||||
|
||||
public abstract DecimalFormat getCurrencyFormat();
|
||||
|
||||
public abstract DecimalFormat getTableCurrencyFormat();
|
||||
|
||||
public String formatBtcValue(Long amount) {
|
||||
return getBtcFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
|
||||
}
|
||||
@@ -94,6 +106,10 @@ public enum UnitFormat {
|
||||
return getCurrencyFormat().format(amount);
|
||||
}
|
||||
|
||||
public String tableFormatCurrencyValue(double amount) {
|
||||
return getTableCurrencyFormat().format(amount);
|
||||
}
|
||||
|
||||
public String getGroupingSeparator() {
|
||||
return Character.toString(getDecimalFormatSymbols().getGroupingSeparator());
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public class WelcomeDialog extends Dialog<Mode> {
|
||||
|
||||
dialogPane.setPrefWidth(600);
|
||||
dialogPane.setPrefHeight(520);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.controlsfx.glyphfont.Glyph;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static com.sparrowwallet.drongo.wallet.StandardAccount.*;
|
||||
|
||||
public class AddAccountDialog extends Dialog<List<StandardAccount>> {
|
||||
private final ComboBox<StandardAccount> standardAccountCombo;
|
||||
private boolean discoverAccounts = false;
|
||||
@@ -53,13 +55,15 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
|
||||
|
||||
List<StandardAccount> availableAccounts = new ArrayList<>();
|
||||
for(StandardAccount standardAccount : StandardAccount.values()) {
|
||||
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) {
|
||||
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.isWhirlpoolAccount(standardAccount)) {
|
||||
availableAccounts.add(standardAccount);
|
||||
}
|
||||
}
|
||||
|
||||
if(WhirlpoolServices.canWalletMix(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
|
||||
availableAccounts.add(StandardAccount.WHIRLPOOL_PREMIX);
|
||||
availableAccounts.add(WHIRLPOOL_PREMIX);
|
||||
} else if(WhirlpoolServices.canWatchPostmix(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
|
||||
availableAccounts.add(WHIRLPOOL_POSTMIX);
|
||||
}
|
||||
|
||||
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
|
||||
@@ -82,10 +86,14 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
|
||||
return "None Available";
|
||||
}
|
||||
|
||||
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(account)) {
|
||||
if(account == WHIRLPOOL_PREMIX) {
|
||||
return "Whirlpool Accounts";
|
||||
}
|
||||
|
||||
if(account == WHIRLPOOL_POSTMIX) {
|
||||
return "Whirlpool Postmix (No mixing)";
|
||||
}
|
||||
|
||||
return account.getName();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
|
||||
if(utxoEntry != null) {
|
||||
Address address = addressStatus.getAddress();
|
||||
setText(address.toString());
|
||||
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false));
|
||||
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false, getTreeTableView()));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setShowDelay(Duration.millis(250));
|
||||
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
|
||||
|
||||
@@ -4,8 +4,7 @@ import com.google.common.base.Throwables;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
@@ -26,18 +25,25 @@ import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
|
||||
|
||||
public class CardImportPane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(CardImportPane.class);
|
||||
|
||||
private final KeystoreCardImport importer;
|
||||
private final List<ChildNumber> derivation;
|
||||
private List<ChildNumber> derivation;
|
||||
protected Button importButton;
|
||||
private final SimpleStringProperty pin = new SimpleStringProperty("");
|
||||
|
||||
@@ -62,11 +68,8 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void importCard() {
|
||||
if(pin.get().length() < 6) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
setContent(getPinEntry());
|
||||
showHideLink.setVisible(false);
|
||||
setExpanded(true);
|
||||
if(!isReaderAvailable()) {
|
||||
setError("No reader", "No card reader was detected.");
|
||||
importButton.setDisable(false);
|
||||
return;
|
||||
}
|
||||
@@ -77,6 +80,15 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
});
|
||||
|
||||
try {
|
||||
if(pin.get().length() < importer.getWalletModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? (!importer.getWalletModel().hasDefaultPin() && !importer.isInitialized() ? "Choose a PIN code" : "Enter PIN code") : "PIN code too short");
|
||||
setContent(getPinAndDerivationEntry());
|
||||
showHideLink.setVisible(false);
|
||||
setExpanded(true);
|
||||
importButton.setDisable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!importer.isInitialized()) {
|
||||
setDescription("Card not initialized");
|
||||
setContent(getInitializationPanel(messageProperty));
|
||||
@@ -98,7 +110,7 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
Throwable rootCause = Throwables.getRootCause(event.getSource().getException());
|
||||
if(rootCause instanceof CardAuthorizationException) {
|
||||
setError(rootCause.getMessage(), null);
|
||||
setContent(getPinEntry());
|
||||
setContent(getPinAndDerivationEntry());
|
||||
} else {
|
||||
log.error("Error importing keystore from card", event.getSource().getException());
|
||||
setError("Import Error", rootCause.getMessage());
|
||||
@@ -109,6 +121,75 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private Node getInitializationPanel(StringProperty messageProperty) {
|
||||
if(importer.getWalletModel().requiresSeedInitialization()) {
|
||||
return getSeedInitializationPanel(messageProperty);
|
||||
}
|
||||
|
||||
return getEntropyInitializationPanel(messageProperty);
|
||||
}
|
||||
|
||||
private Node getSeedInitializationPanel(StringProperty messageProperty) {
|
||||
VBox confirmationBox = new VBox(5);
|
||||
CustomPasswordField confirmationPin = new ViewPasswordField();
|
||||
confirmationPin.setPromptText("Re-enter chosen PIN");
|
||||
confirmationBox.getChildren().add(confirmationPin);
|
||||
|
||||
Button initializeButton = new Button("Initialize");
|
||||
initializeButton.setDefaultButton(true);
|
||||
initializeButton.setOnAction(event -> {
|
||||
initializeButton.setDisable(true);
|
||||
if(!pin.get().equals(confirmationPin.getText())) {
|
||||
setError("PIN Error", "The confirmation PIN did not match");
|
||||
return;
|
||||
}
|
||||
int pinSize = pin.get().length();
|
||||
if(pinSize < importer.getWalletModel().getMinPinLength() || pinSize > importer.getWalletModel().getMaxPinLength()) {
|
||||
setError("PIN Error", "PIN length must be between " + importer.getWalletModel().getMinPinLength() + " and " + importer.getWalletModel().getMaxPinLength() + " characters");
|
||||
return;
|
||||
}
|
||||
|
||||
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(importer.getWalletModel().toDisplayString() + " Seed Words", 12);
|
||||
seedEntryDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
try {
|
||||
List<String> mnemonicWords = optWords.get();
|
||||
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
|
||||
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
|
||||
byte[] seedBytes = seed.getSeedBytes();
|
||||
|
||||
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), seedBytes, messageProperty);
|
||||
cardInitializationService.setOnSucceeded(successEvent -> {
|
||||
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
|
||||
setDescription("Leave card on reader");
|
||||
setExpanded(false);
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
cardInitializationService.setOnFailed(failEvent -> {
|
||||
log.error("Error initializing card", failEvent.getSource().getException());
|
||||
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
});
|
||||
cardInitializationService.start();
|
||||
} catch(MnemonicException e) {
|
||||
log.error("Invalid seed entered", e);
|
||||
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
} else {
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox(20);
|
||||
contentBox.getChildren().addAll(confirmationBox, initializeButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getEntropyInitializationPanel(StringProperty messageProperty) {
|
||||
VBox initTypeBox = new VBox(5);
|
||||
RadioButton automatic = new RadioButton("Automatic (Recommended)");
|
||||
RadioButton advanced = new RadioButton("Advanced");
|
||||
@@ -161,6 +242,13 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getPinAndDerivationEntry() {
|
||||
VBox vBox = new VBox();
|
||||
vBox.getChildren().add(getPinEntry());
|
||||
vBox.getChildren().add(getDerivationEntry());
|
||||
return vBox;
|
||||
}
|
||||
|
||||
private Node getPinEntry() {
|
||||
VBox vBox = new VBox();
|
||||
|
||||
@@ -175,7 +263,7 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
contentBox.setAlignment(Pos.TOP_RIGHT);
|
||||
contentBox.setSpacing(20);
|
||||
contentBox.getChildren().add(pinField);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
contentBox.setPadding(new Insets(10, 30, 0, 30));
|
||||
contentBox.setPrefHeight(50);
|
||||
|
||||
vBox.getChildren().add(contentBox);
|
||||
@@ -183,6 +271,51 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
return vBox;
|
||||
}
|
||||
|
||||
private Node getDerivationEntry() {
|
||||
VBox vBox = new VBox();
|
||||
|
||||
CheckBox checkBox = new CheckBox("Use Custom Derivation");
|
||||
Label customLabel = new Label("Derivation:");
|
||||
TextField customDerivation = new TextField(KeyDerivation.writePath(derivation));
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.registerValidator(customDerivation, Validator.combine(
|
||||
Validator.createEmptyValidator("Derivation is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
|
||||
));
|
||||
|
||||
customDerivation.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue.isEmpty() || !KeyDerivation.isValid(newValue)) {
|
||||
importButton.setDisable(true);
|
||||
} else {
|
||||
importButton.setDisable(false);
|
||||
derivation = KeyDerivation.parsePath(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
checkBox.managedProperty().bind(checkBox.visibleProperty());
|
||||
customLabel.managedProperty().bind(customLabel.visibleProperty());
|
||||
customDerivation.managedProperty().bind(customDerivation.visibleProperty());
|
||||
customLabel.visibleProperty().bind(checkBox.visibleProperty().not());
|
||||
customDerivation.visibleProperty().bind(checkBox.visibleProperty().not());
|
||||
|
||||
checkBox.selectedProperty().addListener((observable, oldValue, newValue) -> {
|
||||
checkBox.setVisible(false);
|
||||
});
|
||||
|
||||
HBox derivationBox = new HBox();
|
||||
derivationBox.setAlignment(Pos.CENTER_LEFT);
|
||||
derivationBox.setSpacing(20);
|
||||
derivationBox.getChildren().addAll(checkBox, customLabel, customDerivation);
|
||||
derivationBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
derivationBox.setPrefHeight(50);
|
||||
|
||||
vBox.getChildren().addAll(derivationBox);
|
||||
|
||||
return vBox;
|
||||
}
|
||||
|
||||
public static class CardInitializationService extends Service<Void> {
|
||||
private final KeystoreCardImport cardImport;
|
||||
private final String pin;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
@@ -22,20 +23,25 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
|
||||
private final CheckBox backupFirst;
|
||||
private final ButtonType okButtonType;
|
||||
|
||||
public CardPinDialog() {
|
||||
public CardPinDialog(WalletModel walletModel, boolean backupOnly) {
|
||||
this.existingPin = new ViewPasswordField();
|
||||
this.newPin = new ViewPasswordField();
|
||||
this.newPinConfirm = new ViewPasswordField();
|
||||
this.backupFirst = new CheckBox();
|
||||
|
||||
if(backupOnly) {
|
||||
newPin.textProperty().bind(existingPin.textProperty());
|
||||
newPinConfirm.textProperty().bind(existingPin.textProperty());
|
||||
}
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
setTitle("Change Card PIN");
|
||||
dialogPane.setHeaderText("Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
|
||||
setTitle(backupOnly ? "Backup Card" : "Change Card PIN");
|
||||
dialogPane.setHeaderText(backupOnly ? "Enter the current card PIN." : "Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits.");
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
|
||||
dialogPane.setPrefWidth(380);
|
||||
dialogPane.setPrefHeight(260);
|
||||
dialogPane.setPrefHeight(backupOnly ? 135 : 260);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
|
||||
@@ -63,24 +69,33 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
|
||||
backupField.setText("Backup First:");
|
||||
backupField.getInputs().add(backupFirst);
|
||||
|
||||
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
|
||||
if(backupOnly) {
|
||||
fieldset.getChildren().addAll(currentField);
|
||||
} else {
|
||||
fieldset.getChildren().addAll(currentField, newField, confirmField);
|
||||
}
|
||||
|
||||
if(walletModel.supportsBackup()) {
|
||||
fieldset.getChildren().add(backupField);
|
||||
}
|
||||
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater( () -> {
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < 6 || existingPin.getText().length() > 32));
|
||||
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < 6 || newPin.getText().length() > 32));
|
||||
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()));
|
||||
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()));
|
||||
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
|
||||
});
|
||||
|
||||
okButtonType = new javafx.scene.control.ButtonType("Change", ButtonBar.ButtonData.OK_DONE);
|
||||
okButtonType = new javafx.scene.control.ButtonType(backupOnly ? "Backup" : "Change", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
okButton.setPrefWidth(130);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < 6 || existingPin.getText().length() > 32
|
||||
|| newPin.getText().length() < 6 || newPin.getText().length() > 32
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()
|
||||
|| newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()
|
||||
|| !newPin.getText().equals(newPinConfirm.getText()),
|
||||
existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
@@ -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})?");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
@@ -17,6 +18,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
@@ -28,6 +30,7 @@ import java.util.Optional;
|
||||
public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
private BitcoinUnit bitcoinUnit;
|
||||
private UnitFormat unitFormat;
|
||||
private CurrencyRate currencyRate;
|
||||
|
||||
public BitcoinUnit getBitcoinUnit() {
|
||||
return bitcoinUnit;
|
||||
@@ -64,6 +67,18 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
}
|
||||
}
|
||||
|
||||
public CurrencyRate getCurrencyRate() {
|
||||
return currencyRate;
|
||||
}
|
||||
|
||||
public void setCurrencyRate(CurrencyRate currencyRate) {
|
||||
this.currencyRate = currencyRate;
|
||||
|
||||
if(!getChildren().isEmpty()) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
|
||||
if(getRoot() != null) {
|
||||
Entry entry = getRoot().getValue();
|
||||
@@ -94,6 +109,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
hyperlink.setTranslateY(30);
|
||||
hyperlink.setOnAction(event -> {
|
||||
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate(), false);
|
||||
dlg.initOwner(this.getScene().getWindow());
|
||||
Optional<Date> optDate = dlg.showAndWait();
|
||||
if(optDate.isPresent()) {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
@@ -119,4 +135,12 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
stackPane.setAlignment(Pos.CENTER);
|
||||
return stackPane;
|
||||
}
|
||||
|
||||
public void setSortColumn(int columnIndex, TreeTableColumn.SortType sortType) {
|
||||
if(columnIndex >= 0 && columnIndex < getColumns().size() && getSortOrder().isEmpty() && !getRoot().getChildren().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> column = getColumns().get(columnIndex);
|
||||
column.setSortType(sortType == null ? TreeTableColumn.SortType.DESCENDING : sortType);
|
||||
getSortOrder().add(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -12,6 +11,8 @@ import javafx.util.Duration;
|
||||
import java.text.DateFormat;
|
||||
import java.text.SimpleDateFormat;
|
||||
|
||||
import static com.sparrowwallet.sparrow.control.EntryCell.HashIndexEntryContextMenu;
|
||||
|
||||
public class DateCell extends TreeTableCell<Entry, Entry> {
|
||||
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
|
||||
@@ -36,11 +37,11 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
|
||||
UtxoEntry utxoEntry = (UtxoEntry)entry;
|
||||
if(utxoEntry.getHashIndex().getHeight() <= 0) {
|
||||
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")));
|
||||
setContextMenu(null);
|
||||
setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), utxoEntry));
|
||||
} else if(utxoEntry.getHashIndex().getDate() != null) {
|
||||
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
|
||||
setText(date);
|
||||
setContextMenu(new DateContextMenu(date, utxoEntry.getHashIndex()));
|
||||
setContextMenu(new DateContextMenu(getTreeTableView(), utxoEntry, date));
|
||||
} else {
|
||||
setText("Unknown");
|
||||
setContextMenu(null);
|
||||
@@ -56,8 +57,10 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
|
||||
}
|
||||
}
|
||||
|
||||
private static class DateContextMenu extends ContextMenu {
|
||||
public DateContextMenu(String date, BlockTransactionHashIndex reference) {
|
||||
private static class DateContextMenu extends HashIndexEntryContextMenu {
|
||||
public DateContextMenu(TreeTableView<Entry> treeTableView, UtxoEntry utxoEntry, String date) {
|
||||
super(treeTableView, utxoEntry);
|
||||
|
||||
MenuItem copyDate = new MenuItem("Copy Date");
|
||||
copyDate.setOnAction(AE -> {
|
||||
hide();
|
||||
@@ -70,7 +73,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
|
||||
copyHeight.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(reference.getHeight() > 0 ? Integer.toString(reference.getHeight()) : "Mempool");
|
||||
content.putString(utxoEntry.getHashIndex().getHeight() > 0 ? Integer.toString(utxoEntry.getHashIndex().getHeight()) : "Mempool");
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(360);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
|
||||
|
||||
@@ -300,7 +300,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
|
||||
if(importButton instanceof SplitMenuButton importMenuButton) {
|
||||
if(wallet.getScriptType() == null) {
|
||||
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH};
|
||||
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH, ScriptType.P2TR};
|
||||
for(ScriptType scriptType : scriptTypes) {
|
||||
MenuItem item = new MenuItem(scriptType.getDescription());
|
||||
final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
|
||||
@@ -673,8 +673,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
try {
|
||||
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
|
||||
if(!cardApi.isInitialized()) {
|
||||
if(pin.get().length() < 6) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
if(pin.get().length() < device.getModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? (device.getModel().hasDefaultPin() ? "Enter PIN code" : "Choose a PIN code") : "PIN code too short");
|
||||
setContent(getCardPinEntry(importButton));
|
||||
showHideLink.setVisible(false);
|
||||
setExpanded(true);
|
||||
@@ -795,7 +795,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void handleCardOperation(Service<?> service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler<WorkerStateEvent> successHandler) {
|
||||
if(pinRequired && pin.get().length() < 6) {
|
||||
if(pinRequired && pin.get().length() < device.getModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
setContent(getCardPinEntry(operationButton));
|
||||
showHideLink.setVisible(false);
|
||||
@@ -940,7 +940,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
try {
|
||||
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
|
||||
if(!cardApi.isInitialized()) {
|
||||
if(pin.get().length() < 6) {
|
||||
if(pin.get().length() < device.getModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
setContent(getCardPinEntry(getAddressButton));
|
||||
showHideLink.setVisible(false);
|
||||
@@ -1047,6 +1047,75 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
|
||||
if(device.getModel().requiresSeedInitialization()) {
|
||||
return getCardSeedInitializationPanel(cardApi, operationButton, deviceOperation);
|
||||
}
|
||||
|
||||
return getCardEntropyInitializationPanel(cardApi, operationButton, deviceOperation);
|
||||
}
|
||||
|
||||
private Node getCardSeedInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
|
||||
VBox confirmationBox = new VBox(5);
|
||||
CustomPasswordField confirmationPin = new ViewPasswordField();
|
||||
confirmationPin.setPromptText("Re-enter chosen PIN");
|
||||
confirmationBox.getChildren().add(confirmationPin);
|
||||
|
||||
Button initializeButton = new Button("Initialize");
|
||||
initializeButton.setDefaultButton(true);
|
||||
initializeButton.setOnAction(event -> {
|
||||
initializeButton.setDisable(true);
|
||||
if(!pin.get().equals(confirmationPin.getText())) {
|
||||
setError("PIN Error", "The confirmation PIN did not match");
|
||||
return;
|
||||
}
|
||||
int pinSize = pin.get().length();
|
||||
if(pinSize < device.getModel().getMinPinLength() || pinSize > device.getModel().getMaxPinLength()) {
|
||||
setError("PIN Error", "PIN length must be between " + device.getModel().getMinPinLength() + " and " + device.getModel().getMaxPinLength() + " characters");
|
||||
return;
|
||||
}
|
||||
|
||||
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(device.getModel().toDisplayString() + " Seed Words", 12);
|
||||
seedEntryDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
try {
|
||||
List<String> mnemonicWords = optWords.get();
|
||||
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
|
||||
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
|
||||
byte[] seedBytes = seed.getSeedBytes();
|
||||
|
||||
Service<Void> cardInitializationService = cardApi.getInitializationService(seedBytes, messageProperty);
|
||||
cardInitializationService.setOnSucceeded(successEvent -> {
|
||||
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
|
||||
operationButton.setDisable(false);
|
||||
setDefaultStatus();
|
||||
setExpanded(false);
|
||||
});
|
||||
cardInitializationService.setOnFailed(failEvent -> {
|
||||
log.error("Error initializing card", failEvent.getSource().getException());
|
||||
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
});
|
||||
cardInitializationService.start();
|
||||
} catch(MnemonicException e) {
|
||||
log.error("Invalid seed entered", e);
|
||||
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
} else {
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox(20);
|
||||
contentBox.getChildren().addAll(confirmationBox, initializeButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getCardEntropyInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
|
||||
VBox initTypeBox = new VBox(5);
|
||||
RadioButton automatic = new RadioButton("Automatic (Recommended)");
|
||||
RadioButton advanced = new RadioButton("Advanced");
|
||||
|
||||
@@ -36,7 +36,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
|
||||
|
||||
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
private static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*)\\(Replaced By Fee( #)?(\\d+)?\\).*");
|
||||
public static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*?)( \\(Replaced By Fee( #)?(\\d+)?\\)).*?");
|
||||
|
||||
private static EntryCell lastCell;
|
||||
|
||||
@@ -100,7 +100,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
actionBox.getChildren().add(viewTransactionButton);
|
||||
|
||||
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
|
||||
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() &&
|
||||
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) &&
|
||||
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
Button increaseFeeButton = new Button("");
|
||||
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
|
||||
@@ -124,7 +124,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
NodeEntry nodeEntry = (NodeEntry)entry;
|
||||
Address address = nodeEntry.getAddress();
|
||||
setText(address.toString());
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true));
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setShowDelay(Duration.millis(250));
|
||||
tooltip.setText(nodeEntry.getNode().toString());
|
||||
@@ -149,6 +149,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
signMessageButton.setGraphic(getSignMessageGlyph());
|
||||
signMessageButton.setOnAction(event -> {
|
||||
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
|
||||
messageSignDialog.initOwner(getTreeTableView().getScene().getWindow());
|
||||
messageSignDialog.showAndWait();
|
||||
});
|
||||
actionBox.getChildren().add(signMessageButton);
|
||||
@@ -216,7 +217,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
|
||||
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
|
||||
.filter(TransactionInput::isReplaceByFeeEnabled)
|
||||
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled())
|
||||
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -230,31 +231,47 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
|
||||
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE)
|
||||
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
|
||||
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
|
||||
Transaction tx = blockTransaction.getTransaction();
|
||||
double vSize = tx.getVirtualSize();
|
||||
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
|
||||
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
|
||||
//Remove any UTXOs created by the transaction that is to be replaced
|
||||
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
|
||||
Collections.shuffle(walletUtxos);
|
||||
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
|
||||
//If there is insufficient change output, include another random UTXO so the fee can be increased
|
||||
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
|
||||
utxos.add(utxo);
|
||||
changeTotal += utxo.getValue();
|
||||
vSize += inputSize;
|
||||
if(changeTotal == 0) {
|
||||
//Add change output length to vSize if change was not present on the original transaction
|
||||
TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getOutputScript());
|
||||
vSize += changeOutput.getLength();
|
||||
}
|
||||
double inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? (double)tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
|
||||
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(utxos), new SpentTxoFilter(blockTransaction.getHash()), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
|
||||
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
|
||||
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
|
||||
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
|
||||
Collections.shuffle(outputGroups);
|
||||
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction) {
|
||||
//If there is insufficient change output, include another random output group so the fee can be increased
|
||||
OutputGroup outputGroup = outputGroups.remove(0);
|
||||
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
|
||||
utxos.add(utxo);
|
||||
changeTotal += utxo.getValue();
|
||||
vSize += inputSize;
|
||||
}
|
||||
}
|
||||
|
||||
Long fee = blockTransaction.getFee();
|
||||
if(fee != null) {
|
||||
//Replacement tx fees must be greater than the original tx fees by its minimum relay cost
|
||||
fee += (long)Math.ceil(vSize * AppServices.getMinimumRelayFeeRate());
|
||||
}
|
||||
Long rbfFee = fee;
|
||||
|
||||
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
|
||||
externalOutputs.removeAll(ourOutputs);
|
||||
@@ -263,17 +280,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
|
||||
try {
|
||||
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
|
||||
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(label);
|
||||
label = REPLACED_BY_FEE_SUFFIX.matcher(label).replaceAll("$1");
|
||||
String[] paymentLabels = label.split(", ");
|
||||
if(externalOutputs.size() > 1 && externalOutputs.size() == paymentLabels.length) {
|
||||
label = paymentLabels[externalOutputs.indexOf(txOutput)];
|
||||
}
|
||||
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel());
|
||||
if(matcher.matches()) {
|
||||
String base = matcher.group(1);
|
||||
if(matcher.groupCount() > 2 && matcher.group(3) != null) {
|
||||
int count = Integer.parseInt(matcher.group(3)) + 1;
|
||||
label = base + "(Replaced By Fee #" + count + ")";
|
||||
if(matcher.groupCount() > 3 && matcher.group(4) != null) {
|
||||
int count = Integer.parseInt(matcher.group(4)) + 1;
|
||||
label += " (Replaced By Fee #" + count + ")";
|
||||
} else {
|
||||
label = base + "(Replaced By Fee #2)";
|
||||
label += " (Replaced By Fee #2)";
|
||||
}
|
||||
} else {
|
||||
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
|
||||
label += " (Replaced By Fee)";
|
||||
}
|
||||
|
||||
if(txOutput.getScript().getToAddress() != null) {
|
||||
@@ -315,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
}
|
||||
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true)));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction)));
|
||||
}
|
||||
|
||||
private static Double getMaxFeeRate() {
|
||||
@@ -331,30 +352,57 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable())
|
||||
.map(HashIndexEntry::getHashIndex)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if(ourOutputs.isEmpty()) {
|
||||
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend");
|
||||
AppServices.showErrorDialog("No spendable outputs", "None of the outputs on this transaction are spendable.\n\n Ensure that the outputs are not frozen" +
|
||||
(transactionEntry.getConfirmations() <= 0 ? ", and spending unconfirmed UTXOs is allowed." : "."));
|
||||
return;
|
||||
}
|
||||
|
||||
BlockTransactionHashIndex utxo = ourOutputs.get(0);
|
||||
BlockTransactionHashIndex cpfpUtxo = ourOutputs.get(0);
|
||||
Address freshAddress = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress();
|
||||
TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), freshAddress.getOutputScript());
|
||||
long dustThreshold = freshAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
|
||||
double inputSize = freshAddress.getScriptType().getInputVbytes();
|
||||
double vSize = inputSize + txOutput.getLength();
|
||||
|
||||
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(List.of(cpfpUtxo)), new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
|
||||
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
|
||||
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
|
||||
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
|
||||
Collections.shuffle(outputGroups);
|
||||
|
||||
List<BlockTransactionHashIndex> utxos = new ArrayList<>();
|
||||
utxos.add(cpfpUtxo);
|
||||
long inputTotal = cpfpUtxo.getValue();
|
||||
while((inputTotal - (long)(getMaxFeeRate() * vSize)) < dustThreshold && !outputGroups.isEmpty()) {
|
||||
//If there is insufficient input value, include another random output group so the fee can be increased
|
||||
OutputGroup outputGroup = outputGroups.remove(0);
|
||||
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
|
||||
utxos.add(utxo);
|
||||
inputTotal += utxo.getValue();
|
||||
vSize += inputSize;
|
||||
}
|
||||
}
|
||||
|
||||
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
|
||||
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
|
||||
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
|
||||
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true);
|
||||
Payment payment = new Payment(freshAddress, label, inputTotal, true);
|
||||
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false)));
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
|
||||
}
|
||||
|
||||
private static boolean canRBF(BlockTransaction blockTransaction) {
|
||||
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
|
||||
}
|
||||
|
||||
private static boolean canSignMessage(WalletNode walletNode) {
|
||||
Wallet wallet = walletNode.getWallet();
|
||||
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
|
||||
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB || wallet.getKeystores().get(0).getWalletModel().isCard()) &&
|
||||
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
|
||||
return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
|
||||
}
|
||||
|
||||
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
|
||||
@@ -427,7 +475,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
|
||||
}
|
||||
|
||||
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled");
|
||||
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled");
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
@@ -504,7 +552,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
});
|
||||
getItems().add(viewTransaction);
|
||||
|
||||
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
|
||||
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
|
||||
increaseFee.setOnAction(AE -> {
|
||||
@@ -515,7 +563,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
getItems().add(increaseFee);
|
||||
}
|
||||
|
||||
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
|
||||
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
|
||||
cancelTx.setOnAction(AE -> {
|
||||
@@ -600,7 +648,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
}
|
||||
|
||||
public static class AddressContextMenu extends ContextMenu {
|
||||
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems) {
|
||||
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems, TreeTableView<Entry> treetable) {
|
||||
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
|
||||
MenuItem receiveToAddress = new MenuItem("Receive To");
|
||||
receiveToAddress.setGraphic(getReceiveGlyph());
|
||||
@@ -618,6 +666,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
signVerifyMessage.setOnAction(AE -> {
|
||||
hide();
|
||||
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
|
||||
messageSignDialog.initOwner(treetable.getScene().getWindow());
|
||||
messageSignDialog.showAndWait();
|
||||
});
|
||||
getItems().add(signVerifyMessage);
|
||||
@@ -691,7 +740,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
}
|
||||
}
|
||||
|
||||
private static class HashIndexEntryContextMenu extends ContextMenu {
|
||||
static class HashIndexEntryContextMenu extends ContextMenu {
|
||||
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
|
||||
MenuItem viewTransaction = new MenuItem("View Transaction");
|
||||
viewTransaction.setGraphic(getViewTransactionGlyph());
|
||||
@@ -747,6 +796,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
cell.getStyleClass().remove("transaction-row");
|
||||
cell.getStyleClass().remove("node-row");
|
||||
cell.getStyleClass().remove("utxo-row");
|
||||
cell.getStyleClass().remove("unconfirmed-row");
|
||||
cell.getStyleClass().remove("summary-row");
|
||||
cell.getStyleClass().remove("address-cell");
|
||||
cell.getStyleClass().remove("hashindex-row");
|
||||
cell.getStyleClass().remove("confirming");
|
||||
@@ -781,6 +832,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
if(hashIndexEntry.isSpent()) {
|
||||
cell.getStyleClass().add("spent");
|
||||
}
|
||||
} else if(entry instanceof WalletSummaryDialog.UnconfirmedEntry) {
|
||||
cell.getStyleClass().add("unconfirmed-row");
|
||||
} else if(entry instanceof WalletSummaryDialog.SummaryEntry) {
|
||||
cell.getStyleClass().add("summary-row");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.net.FeeRatesSource;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.*;
|
||||
|
||||
public class FeeRangeSlider extends Slider {
|
||||
public FeeRangeSlider() {
|
||||
super(0, FEE_RATES_RANGE.size() - 1, 0);
|
||||
setMajorTickUnit(1);
|
||||
setMinorTickCount(0);
|
||||
setSnapToTicks(false);
|
||||
setShowTickLabels(true);
|
||||
setShowTickMarks(true);
|
||||
setBlockIncrement(Math.log(1.02) / Math.log(2));
|
||||
|
||||
setLabelFormatter(new StringConverter<>() {
|
||||
@Override
|
||||
public String toString(Double object) {
|
||||
Long feeRate = LONG_FEE_RATES_RANGE.get(object.intValue());
|
||||
if(isLongFeeRange() && feeRate >= 1000) {
|
||||
return feeRate / 1000 + "k";
|
||||
}
|
||||
return Long.toString(feeRate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double fromString(String string) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
updateTrackHighlight();
|
||||
|
||||
valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue != null) {
|
||||
updateMaxFeeRange(newValue.doubleValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public double getFeeRate() {
|
||||
return Math.pow(2.0, getValue());
|
||||
}
|
||||
|
||||
public void setFeeRate(double feeRate) {
|
||||
double value = Math.log(feeRate) / Math.log(2);
|
||||
updateMaxFeeRange(value);
|
||||
setValue(value);
|
||||
}
|
||||
|
||||
private void updateMaxFeeRange(double value) {
|
||||
if(value >= getMax() && !isLongFeeRange()) {
|
||||
setMax(LONG_FEE_RATES_RANGE.size() - 1);
|
||||
updateTrackHighlight();
|
||||
} else if(value == getMin() && isLongFeeRange()) {
|
||||
setMax(FEE_RATES_RANGE.size() - 1);
|
||||
updateTrackHighlight();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLongFeeRange() {
|
||||
return getMax() > FEE_RATES_RANGE.size() - 1;
|
||||
}
|
||||
|
||||
public void updateTrackHighlight() {
|
||||
addFeeRangeTrackHighlight(0);
|
||||
}
|
||||
|
||||
private void addFeeRangeTrackHighlight(int count) {
|
||||
Platform.runLater(() -> {
|
||||
Node track = lookup(".track");
|
||||
if(track != null) {
|
||||
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
||||
String highlight = "";
|
||||
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
|
||||
highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
|
||||
}
|
||||
highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
|
||||
highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
|
||||
highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";
|
||||
|
||||
track.setStyle("-fx-background-color: " +
|
||||
"-fx-shadow-highlight-color, " +
|
||||
"linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), " +
|
||||
"linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)), " +
|
||||
"linear-gradient(to right, " + highlight + ")");
|
||||
} else if(count < 20) {
|
||||
addFeeRangeTrackHighlight(count+1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<Integer, Double> getTargetBlocksFeeRates() {
|
||||
Map<Integer, Double> retrievedFeeRates = AppServices.getTargetBlockFeeRates();
|
||||
if(retrievedFeeRates == null) {
|
||||
retrievedFeeRates = TARGET_BLOCKS_RANGE.stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> getFallbackFeeRate(),
|
||||
(u, v) -> { throw new IllegalStateException("Duplicate target blocks"); },
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
return retrievedFeeRates;
|
||||
}
|
||||
|
||||
private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
|
||||
List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
|
||||
Collections.reverse(rates);
|
||||
for(Integer targetBlocks : rates) {
|
||||
if(targetBlocks < minTargetBlocks) {
|
||||
return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
|
||||
}
|
||||
}
|
||||
|
||||
return 100;
|
||||
}
|
||||
|
||||
private int getPercentageOfFeeRange(Double feeRate) {
|
||||
double index = Math.log(feeRate) / Math.log(2);
|
||||
if(isLongFeeRange()) {
|
||||
index *= ((double)FEE_RATES_RANGE.size() / (LONG_FEE_RATES_RANGE.size())) * 0.99;
|
||||
}
|
||||
return (int)Math.round(index * 10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import org.controlsfx.tools.Platform;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
|
||||
public class FiatCell extends TreeTableCell<Entry, Number> {
|
||||
private final Tooltip tooltip;
|
||||
private final FiatContextMenu contextMenu;
|
||||
|
||||
public FiatCell() {
|
||||
super();
|
||||
tooltip = new Tooltip();
|
||||
contextMenu = new FiatContextMenu();
|
||||
getStyleClass().add("coin-cell");
|
||||
if(Platform.getCurrent() == Platform.OSX) {
|
||||
getStyleClass().add("number-field");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Number amount, boolean empty) {
|
||||
super.updateItem(amount, empty);
|
||||
|
||||
if(empty || amount == null) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
} else {
|
||||
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
|
||||
EntryCell.applyRowStyles(this, entry);
|
||||
|
||||
CoinTreeTable coinTreeTable = (CoinTreeTable) getTreeTableView();
|
||||
UnitFormat format = coinTreeTable.getUnitFormat();
|
||||
CurrencyRate currencyRate = coinTreeTable.getCurrencyRate();
|
||||
|
||||
if(currencyRate != null && currencyRate.isAvailable()) {
|
||||
Currency currency = currencyRate.getCurrency();
|
||||
double btcRate = currencyRate.getBtcRate();
|
||||
|
||||
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
|
||||
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
|
||||
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
|
||||
|
||||
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
|
||||
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
|
||||
|
||||
setText(label);
|
||||
setGraphic(null);
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
} else {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FiatContextMenu extends ContextMenu {
|
||||
public FiatContextMenu() {
|
||||
MenuItem copyValue = new MenuItem("Copy Value");
|
||||
copyValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyRate = new MenuItem("Copy Rate");
|
||||
copyRate.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getTooltip().getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copyValue, copyRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
|
||||
private void importQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
|
||||
@@ -116,6 +116,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
|
||||
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getWalletModel().isCard()) {
|
||||
TextAreaDialog dialog = new TextAreaDialog(message, false);
|
||||
dialog.initOwner(this.getScene().getWindow());
|
||||
dialog.setTitle("Sign " + exporter.getName() + " Export");
|
||||
dialog.getDialogPane().setHeaderText("The following text needs to be signed by the device.\nClick OK to continue.");
|
||||
dialog.showAndWait();
|
||||
@@ -126,6 +127,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
List<String> operationFingerprints = List.of(keystore.getKeyDerivation().getMasterFingerprint());
|
||||
|
||||
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(operationFingerprints, wallet, message, keystore.getKeyDerivation());
|
||||
deviceSignMessageDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
|
||||
if(optSignature.isPresent()) {
|
||||
exporter.addSignature(keystore, optSignature.get(), baos);
|
||||
@@ -155,6 +157,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
} catch(Exception e) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.hummingbird.registry.CryptoOutput;
|
||||
import com.sparrowwallet.hummingbird.registry.RegistryType;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
@@ -10,6 +13,8 @@ import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletExportEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Control;
|
||||
@@ -24,6 +29,8 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.sparrowwallet.sparrow.wallet.SettingsController.getCryptoOutput;
|
||||
|
||||
public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
private final Wallet wallet;
|
||||
private final WalletExport exporter;
|
||||
@@ -111,6 +118,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(buttonBox.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
|
||||
@@ -140,10 +148,19 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
private void exportWallet(File file, Wallet exportWallet) {
|
||||
try {
|
||||
if(file != null) {
|
||||
try(OutputStream outputStream = new FileOutputStream(file)) {
|
||||
exporter.exportWallet(exportWallet, outputStream);
|
||||
FileWalletExportService fileWalletExportService = new FileWalletExportService(exporter, file, exportWallet);
|
||||
fileWalletExportService.setOnSucceeded(event -> {
|
||||
EventManager.get().post(new WalletExportEvent(exportWallet));
|
||||
}
|
||||
});
|
||||
fileWalletExportService.setOnFailed(event -> {
|
||||
Throwable e = event.getSource().getException();
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
setError("Export Error", errorMessage);
|
||||
});
|
||||
fileWalletExportService.start();
|
||||
} else {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
exporter.exportWallet(exportWallet, outputStream);
|
||||
@@ -152,9 +169,14 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
|
||||
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig || exporter instanceof Bip129) {
|
||||
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
|
||||
} else if(exporter instanceof Descriptor) {
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
|
||||
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
|
||||
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR());
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
} catch(Exception e) {
|
||||
@@ -165,4 +187,30 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
setError("Export Error", errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public static class FileWalletExportService extends Service<Void> {
|
||||
private final WalletExport exporter;
|
||||
private final File file;
|
||||
private final Wallet wallet;
|
||||
|
||||
public FileWalletExportService(WalletExport exporter, File file, Wallet wallet) {
|
||||
this.exporter = exporter;
|
||||
this.file = file;
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected Void call() throws Exception {
|
||||
try(OutputStream outputStream = new FileOutputStream(file)) {
|
||||
exporter.exportWallet(wallet, outputStream);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane {
|
||||
private final WalletImport importer;
|
||||
|
||||
public FileWalletImportPane(WalletImport importer) {
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), true);
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), importer.isWalletImportFileFormatAvailable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.Theme;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.EventHandler;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.chart.*;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.paint.Color;
|
||||
import javafx.stage.Modality;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.stage.StageStyle;
|
||||
import javafx.util.Duration;
|
||||
import javafx.util.StringConverter;
|
||||
import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.text.DateFormat;
|
||||
@@ -27,16 +38,100 @@ import java.util.stream.Collectors;
|
||||
|
||||
public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
private static final DateFormat dateFormatter = new SimpleDateFormat("HH:mm");
|
||||
public static final int MAX_PERIOD_HOURS = 2;
|
||||
public static final int DEFAULT_MAX_PERIOD_HOURS = 2;
|
||||
private static final double Y_VALUE_BREAK_MVB = 3.0;
|
||||
private static final List<Integer> FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800);
|
||||
|
||||
private int maxPeriodHours = DEFAULT_MAX_PERIOD_HOURS;
|
||||
private Tooltip tooltip;
|
||||
|
||||
private MempoolSizeFeeRatesChart expandedChart;
|
||||
private final EventHandler<MouseEvent> expandedChartHandler = new EventHandler<>() {
|
||||
@Override
|
||||
public void handle(MouseEvent event) {
|
||||
if(!event.isConsumed() && event.getButton() != MouseButton.SECONDARY) {
|
||||
Stage stage = new Stage(StageStyle.UNDECORATED);
|
||||
stage.setTitle("Mempool by vBytes");
|
||||
stage.initOwner(MempoolSizeFeeRatesChart.this.getScene().getWindow());
|
||||
stage.initModality(Modality.WINDOW_MODAL);
|
||||
stage.setResizable(false);
|
||||
|
||||
StackPane scenePane = new StackPane();
|
||||
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
|
||||
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
|
||||
}
|
||||
|
||||
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
if(Config.get().getTheme() == Theme.DARK) {
|
||||
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
|
||||
}
|
||||
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
|
||||
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
|
||||
|
||||
VBox vBox = new VBox(20);
|
||||
vBox.setPadding(new Insets(20, 20, 20, 20));
|
||||
|
||||
expandedChart = new MempoolSizeFeeRatesChart();
|
||||
expandedChart.initialize();
|
||||
expandedChart.getStyleClass().add("vsizeChart");
|
||||
expandedChart.update(AppServices.getMempoolHistogram());
|
||||
expandedChart.setLegendVisible(false);
|
||||
expandedChart.setAnimated(false);
|
||||
expandedChart.setPrefWidth(700);
|
||||
|
||||
HBox buttonBox = new HBox();
|
||||
buttonBox.setAlignment(Pos.CENTER);
|
||||
|
||||
ToggleGroup periodGroup = new ToggleGroup();
|
||||
ToggleButton period2 = new ToggleButton("2H");
|
||||
ToggleButton period24 = new ToggleButton("24H");
|
||||
SegmentedButton periodButtons = new SegmentedButton(period2, period24);
|
||||
periodButtons.setToggleGroup(periodGroup);
|
||||
periodGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
|
||||
expandedChart.maxPeriodHours = (newValue == period2 ? 2 : 24);
|
||||
expandedChart.update(AppServices.getMempoolHistogram());
|
||||
});
|
||||
|
||||
Optional<Date> optEarliest = AppServices.getMempoolHistogram().keySet().stream().findFirst();
|
||||
period24.setDisable(optEarliest.isEmpty() || optEarliest.get().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime().isAfter(LocalDateTime.now().minusHours(2)));
|
||||
|
||||
Region region = new Region();
|
||||
HBox.setHgrow(region, Priority.SOMETIMES);
|
||||
|
||||
Button button = new Button("Close");
|
||||
button.setOnAction(e -> {
|
||||
stage.close();
|
||||
});
|
||||
buttonBox.getChildren().addAll(periodButtons, region, button);
|
||||
vBox.getChildren().addAll(expandedChart, buttonBox);
|
||||
scenePane.getChildren().add(vBox);
|
||||
|
||||
Scene scene = new Scene(scenePane);
|
||||
AppServices.onEscapePressed(scene, stage::close);
|
||||
AppServices.setStageIcon(stage);
|
||||
stage.setScene(scene);
|
||||
stage.setOnShowing(e -> {
|
||||
AppServices.moveToActiveWindowScreen(stage, 800, 460);
|
||||
});
|
||||
stage.setOnHidden(e -> {
|
||||
expandedChart = null;
|
||||
});
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public MempoolSizeFeeRatesChart() {
|
||||
super(new CategoryAxis(), new NumberAxis());
|
||||
}
|
||||
|
||||
public MempoolSizeFeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
|
||||
super(xAxis, yAxis);
|
||||
setOnMouseClicked(expandedChartHandler);
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
getStyleClass().add("vsizeChart");
|
||||
setCreateSymbols(false);
|
||||
setCursor(Cursor.CROSSHAIR);
|
||||
setVerticalGridLinesVisible(false);
|
||||
@@ -78,17 +173,18 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
}
|
||||
});
|
||||
|
||||
long previousFeeRate = 0;
|
||||
for(Long feeRate : AppServices.FEE_RATES_RANGE) {
|
||||
for(int i = 0; i < FEE_RATES_INTERVALS.size(); i++) {
|
||||
int feeRate = FEE_RATES_INTERVALS.get(i);
|
||||
int nextFeeRate = (i == FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : FEE_RATES_INTERVALS.get(i+1));
|
||||
XYChart.Series<String, Number> series = new XYChart.Series<>();
|
||||
series.setName(feeRate + "+ sats/vB");
|
||||
series.setName(feeRate + "-" + (nextFeeRate == Integer.MAX_VALUE ? 900 : nextFeeRate));
|
||||
long seriesTotalVSize = 0;
|
||||
|
||||
for(Date date : periodRateSizes.keySet()) {
|
||||
Set<MempoolRateSize> rateSizes = periodRateSizes.get(date);
|
||||
long totalVSize = 0;
|
||||
for(MempoolRateSize rateSize : rateSizes) {
|
||||
if(rateSize.getFee() > previousFeeRate && rateSize.getFee() <= feeRate) {
|
||||
if(rateSize.getFee() >= feeRate && rateSize.getFee() < nextFeeRate) {
|
||||
totalVSize += rateSize.getVSize();
|
||||
}
|
||||
}
|
||||
@@ -100,8 +196,19 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
if(seriesTotalVSize > 0) {
|
||||
getData().add(series);
|
||||
}
|
||||
}
|
||||
|
||||
previousFeeRate = feeRate;
|
||||
for(int i = 0; i < getData().size(); i++) {
|
||||
Series<String, Number> series = getData().get(i);
|
||||
Set<Node> nodes = lookupAll(".series" + i);
|
||||
for(Node node : nodes) {
|
||||
if(node.getStyleClass().contains("chart-series-area-line")) {
|
||||
node.setStyle("-fx-stroke: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.2;");
|
||||
} else {
|
||||
node.setStyle("-fx-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.5;");
|
||||
}
|
||||
node.getStyleClass().remove("default-color" + i);
|
||||
}
|
||||
}
|
||||
|
||||
final double maxMvB = getMaxMvB(getData());
|
||||
@@ -131,6 +238,10 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
numberAxis.setTickLabelsVisible(false);
|
||||
numberAxis.setOpacity(0);
|
||||
}
|
||||
|
||||
if(expandedChart != null) {
|
||||
expandedChart.update(mempoolRateSizes);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Date, Set<MempoolRateSize>> getPeriodRateSizes(Map<Date, Set<MempoolRateSize>> mempoolRateSizes) {
|
||||
@@ -138,7 +249,7 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
return mempoolRateSizes;
|
||||
}
|
||||
|
||||
LocalDateTime period = LocalDateTime.now().minusHours(MAX_PERIOD_HOURS);
|
||||
LocalDateTime period = LocalDateTime.now().minusHours(maxPeriodHours);
|
||||
return mempoolRateSizes.entrySet().stream().filter(entry -> {
|
||||
LocalDateTime dateTime = entry.getKey().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
return dateTime.isAfter(period);
|
||||
@@ -200,11 +311,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
double mvb = kvb / 1000;
|
||||
if(mvb >= 0.01 || (maxMvB < Y_VALUE_BREAK_MVB && mvb > 0.001)) {
|
||||
String amount = (maxMvB < Y_VALUE_BREAK_MVB ? (int)kvb + " kvB" : String.format("%.2f", mvb) + " MvB");
|
||||
Label label = new Label(series.getName() + ": " + amount);
|
||||
Label label = new Label(series.getName() + " sats/vB: " + amount);
|
||||
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CIRCLE);
|
||||
if(i < 8) {
|
||||
circle.setStyle("-fx-text-fill: CHART_COLOR_" + (i+1));
|
||||
}
|
||||
circle.setStyle("-fx-text-fill: VSIZE" + series.getName() + "_COLOR; -fx-opacity: 0.7;");
|
||||
label.setGraphic(circle);
|
||||
getChildren().add(label);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@ import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.crypto.Bip322;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
@@ -22,6 +22,7 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
@@ -37,6 +38,8 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
|
||||
|
||||
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
|
||||
|
||||
@@ -46,9 +49,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
private final ToggleGroup formatGroup;
|
||||
private final ToggleButton formatTrezor;
|
||||
private final ToggleButton formatElectrum;
|
||||
private final ToggleButton formatBip322;
|
||||
private final Wallet wallet;
|
||||
private WalletNode walletNode;
|
||||
private boolean electrumSignatureFormat;
|
||||
private boolean canSign;
|
||||
private boolean closed;
|
||||
|
||||
/**
|
||||
@@ -89,10 +93,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
|
||||
if(walletNode != null) {
|
||||
checkWalletSigning(walletNode.getWallet());
|
||||
this.canSign = canSign(walletNode.getWallet());
|
||||
}
|
||||
|
||||
if(wallet != null) {
|
||||
checkWalletSigning(wallet);
|
||||
this.canSign = canSign(wallet);
|
||||
}
|
||||
|
||||
this.wallet = wallet;
|
||||
@@ -125,7 +131,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
address = new TextField();
|
||||
address.getStyleClass().add("id");
|
||||
address.setEditable(walletNode == null);
|
||||
address.setTooltip(new Tooltip("Only Legacy (P2PKH), Nested Segwit (P2SH-P2WPKH) and Native Segwit (P2WPKH) singlesig addresses can sign"));
|
||||
address.setTooltip(new Tooltip("Only singlesig addresses can sign"));
|
||||
addressField.getInputs().add(address);
|
||||
|
||||
if(walletNode != null) {
|
||||
@@ -136,17 +142,18 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
messageField.setText("Message:");
|
||||
message = new TextArea();
|
||||
message.setWrapText(true);
|
||||
message.setPrefRowCount(10);
|
||||
message.setStyle("-fx-pref-height: 180px");
|
||||
message.setPrefRowCount(8);
|
||||
message.setStyle("-fx-pref-height: 160px");
|
||||
messageField.getInputs().add(message);
|
||||
|
||||
Field signatureField = new Field();
|
||||
signatureField.setText("Signature:");
|
||||
signature = new TextArea();
|
||||
signature.getStyleClass().add("id");
|
||||
signature.setPrefRowCount(2);
|
||||
signature.setStyle("-fx-pref-height: 60px");
|
||||
signature.setPrefRowCount(4);
|
||||
signature.setStyle("-fx-pref-height: 80px");
|
||||
signature.setWrapText(true);
|
||||
signature.setOnMouseClicked(event -> signature.selectAll());
|
||||
signatureField.getInputs().add(signature);
|
||||
|
||||
Field formatField = new Field();
|
||||
@@ -154,16 +161,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
formatGroup = new ToggleGroup();
|
||||
formatElectrum = new ToggleButton("Standard (Electrum)");
|
||||
formatTrezor = new ToggleButton("BIP137 (Trezor)");
|
||||
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor);
|
||||
formatBip322 = new ToggleButton("BIP322 (Simple)");
|
||||
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor, formatBip322);
|
||||
formatButtons.setToggleGroup(formatGroup);
|
||||
formatField.getInputs().add(formatButtons);
|
||||
|
||||
formatGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
|
||||
electrumSignatureFormat = (newValue == formatElectrum);
|
||||
});
|
||||
|
||||
formatButtons.setDisable(wallet != null && walletNode != null && wallet.getScriptType() == ScriptType.P2PKH);
|
||||
|
||||
fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
@@ -176,6 +178,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
formatButtons.setDisable(true);
|
||||
}
|
||||
|
||||
ButtonType showQrButtonType = new javafx.scene.control.ButtonType("Sign by QR", ButtonBar.ButtonData.LEFT);
|
||||
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
|
||||
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
@@ -194,10 +197,20 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
|
||||
dialogPane.getButtonTypes().addAll(showQrButtonType, signButtonType, verifyButtonType, doneButtonType);
|
||||
|
||||
Button showQrButton = (Button) dialogPane.lookupButton(showQrButtonType);
|
||||
showQrButton.setDisable(wallet == null);
|
||||
showQrButton.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE)));
|
||||
showQrButton.setGraphicTextGap(5);
|
||||
showQrButton.setOnAction(event -> {
|
||||
showQr();
|
||||
});
|
||||
|
||||
Button signButton = (Button) dialogPane.lookupButton(signButtonType);
|
||||
signButton.setDisable(wallet == null);
|
||||
signButton.setDisable(!canSign);
|
||||
signButton.setGraphic(getGlyph(getSignGlyph()));
|
||||
signButton.setGraphicTextGap(5);
|
||||
signButton.setOnAction(event -> {
|
||||
signMessage();
|
||||
});
|
||||
@@ -209,7 +222,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
});
|
||||
|
||||
boolean validAddress = isValidAddress();
|
||||
signButton.setDisable(!validAddress || (wallet == null));
|
||||
showQrButton.setDisable(!validAddress || (wallet == null));
|
||||
signButton.setDisable(!validAddress || !canSign);
|
||||
verifyButton.setDisable(!validAddress);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
@@ -220,13 +234,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
|
||||
address.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
boolean valid = isValidAddress();
|
||||
signButton.setDisable(!valid || (wallet == null));
|
||||
showQrButton.setDisable(!valid || (wallet == null));
|
||||
signButton.setDisable(!valid || !canSign);
|
||||
verifyButton.setDisable(!valid);
|
||||
|
||||
if(valid && wallet != null) {
|
||||
if(valid) {
|
||||
try {
|
||||
Address address = getAddress();
|
||||
setWalletNodeFromAddress(wallet, address);
|
||||
setFormatFromScriptType(address.getScriptType());
|
||||
if(wallet != null) {
|
||||
setWalletNodeFromAddress(wallet, address);
|
||||
}
|
||||
} catch(InvalidAddressException e) {
|
||||
//can't happen
|
||||
}
|
||||
@@ -249,7 +267,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
|
||||
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(ButtonBar.ButtonData.CANCEL_CLOSE));
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
|
||||
setResultConverter(dialogButton -> dialogButton == showQrButtonType || dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if(address.getText().isEmpty()) {
|
||||
@@ -258,7 +276,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
message.requestFocus();
|
||||
}
|
||||
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
if(wallet != null && walletNode != null) {
|
||||
setFormatFromScriptType(wallet.getScriptType());
|
||||
} else {
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,9 +288,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
if(wallet.getKeystores().size() != 1) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
|
||||
}
|
||||
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB && !wallet.getKeystores().get(0).getWalletModel().isCard()) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a connected keystore");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canSign(Wallet wallet) {
|
||||
return wallet.getKeystores().get(0).hasPrivateKey()
|
||||
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB
|
||||
|| wallet.getKeystores().get(0).getWalletModel().isCard();
|
||||
}
|
||||
|
||||
private Address getAddress()throws InvalidAddressException {
|
||||
@@ -279,20 +304,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
return signature.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the Electrum signing format, which uses the non-segwit compressed signing parameters for both segwit types (p2sh-p2wpkh and p2wpkh)
|
||||
*
|
||||
* @param electrumSignatureFormat
|
||||
*/
|
||||
public void setElectrumSignatureFormat(boolean electrumSignatureFormat) {
|
||||
formatGroup.selectToggle(electrumSignatureFormat ? formatElectrum : formatTrezor);
|
||||
this.electrumSignatureFormat = electrumSignatureFormat;
|
||||
}
|
||||
|
||||
private boolean isValidAddress() {
|
||||
try {
|
||||
Address address = getAddress();
|
||||
return address.getScriptType() != ScriptType.P2TR && (address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH);
|
||||
return address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH;
|
||||
} catch (InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
@@ -302,12 +317,36 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
walletNode = wallet.getWalletAddresses().get(address);
|
||||
}
|
||||
|
||||
private void setFormatFromScriptType(ScriptType scriptType) {
|
||||
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
|
||||
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
|
||||
formatBip322.setDisable(scriptType != ScriptType.P2WPKH && scriptType != ScriptType.P2TR);
|
||||
if(scriptType == ScriptType.P2TR) {
|
||||
formatGroup.selectToggle(formatBip322);
|
||||
} else if(formatGroup.getSelectedToggle() == null || scriptType == ScriptType.P2PKH || (scriptType != ScriptType.P2WPKH && formatBip322.isSelected())) {
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBip322() {
|
||||
return formatBip322.isSelected();
|
||||
}
|
||||
|
||||
private boolean isElectrumSignatureFormat() {
|
||||
return formatElectrum.isSelected();
|
||||
}
|
||||
|
||||
private void signMessage() {
|
||||
if(walletNode == null) {
|
||||
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
if(!canSign) {
|
||||
AppServices.showErrorDialog("Wallet can't sign", "This wallet cannot sign a message.");
|
||||
return;
|
||||
}
|
||||
|
||||
//Note we can expect a single keystore due to the check in the constructor
|
||||
Wallet signingWallet = walletNode.getWallet();
|
||||
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
|
||||
@@ -325,8 +364,14 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
try {
|
||||
Keystore keystore = decryptedWallet.getKeystores().get(0);
|
||||
ECKey privKey = keystore.getKey(walletNode);
|
||||
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();
|
||||
String signatureText = privKey.signMessage(message.getText().trim(), scriptType);
|
||||
String signatureText;
|
||||
if(isBip322()) {
|
||||
ScriptType scriptType = decryptedWallet.getScriptType();
|
||||
signatureText = Bip322.signMessageBip322(scriptType, message.getText().trim(), privKey);
|
||||
} else {
|
||||
ScriptType scriptType = isElectrumSignatureFormat() ? ScriptType.P2PKH : decryptedWallet.getScriptType();
|
||||
signatureText = privKey.signMessage(message.getText().trim(), scriptType);
|
||||
}
|
||||
signature.clear();
|
||||
signature.appendText(signatureText);
|
||||
privKey.clear();
|
||||
@@ -340,6 +385,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
|
||||
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
|
||||
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
|
||||
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
|
||||
if(optSignature.isPresent()) {
|
||||
signature.clear();
|
||||
@@ -353,16 +399,33 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
|
||||
boolean verified = false;
|
||||
try {
|
||||
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), false);
|
||||
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), true);
|
||||
verified = verifyMessage(signedMessageKey);
|
||||
if(verified) {
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
if(!verified) {
|
||||
try {
|
||||
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
|
||||
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), false);
|
||||
verified = verifyMessage(electrumSignedMessageKey);
|
||||
if(verified) {
|
||||
formatGroup.selectToggle(formatTrezor);
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
if(!verified && Bip322.isSupported(getAddress().getScriptType()) && !signature.getText().trim().isEmpty()) {
|
||||
try {
|
||||
verified = Bip322.verifyMessageBip322(getAddress().getScriptType(), getAddress(), message.getText().trim(), signature.getText().trim());
|
||||
if(verified) {
|
||||
formatGroup.selectToggle(formatBip322);
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
@@ -395,6 +458,60 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
return providedAddress.equals(signedMessageAddress);
|
||||
}
|
||||
|
||||
private void showQr() {
|
||||
if(walletNode == null) {
|
||||
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
//Note we can expect a single keystore due to the check in the constructor
|
||||
KeyDerivation firstDerivation = walletNode.getWallet().getKeystores().get(0).getKeyDerivation();
|
||||
String derivationPath = KeyDerivation.writePath(firstDerivation.extend(walletNode.getDerivation()).getDerivation(), false);
|
||||
|
||||
String qrText = "signmessage " + derivationPath + " ascii:" + message.getText().trim();
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(qrText, true);
|
||||
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
|
||||
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
scanQr();
|
||||
}
|
||||
}
|
||||
|
||||
private void scanQr() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.payload != null) {
|
||||
signature.clear();
|
||||
signature.appendText(result.payload);
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error scanning QR", result.exception);
|
||||
showErrorDialog("Error scanning QR", result.exception.getMessage());
|
||||
} else {
|
||||
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a signature.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Glyph getSignGlyph() {
|
||||
if(wallet != null) {
|
||||
if(wallet.containsSource(KeystoreSource.HW_USB)) {
|
||||
return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
|
||||
} else if(wallet.getKeystores().get(0).getWalletModel().isCard()) {
|
||||
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
|
||||
}
|
||||
}
|
||||
|
||||
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
|
||||
}
|
||||
|
||||
private static Glyph getGlyph(Glyph glyph) {
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
Storage storage = event.getStorage(wallet);
|
||||
@@ -404,6 +521,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
}
|
||||
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get());
|
||||
@@ -415,7 +533,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
});
|
||||
decryptWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
|
||||
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
|
||||
AppServices.showErrorDialog("Incorrect Password", "The password was incorrect.");
|
||||
});
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
|
||||
decryptWalletService.start();
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.PdfUtils;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.scene.text.Text;
|
||||
import javafx.stage.FileChooser;
|
||||
import org.controlsfx.control.spreadsheet.*;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
public class MnemonicGridDialog extends Dialog<List<String>> {
|
||||
private final SpreadsheetView spreadsheetView;
|
||||
|
||||
private final int GRID_COLUMN_COUNT = 16;
|
||||
|
||||
private final BooleanProperty initializedProperty = new SimpleBooleanProperty(false);
|
||||
private final BooleanProperty wordsSelectedProperty = new SimpleBooleanProperty(false);
|
||||
|
||||
private final ObservableList<TablePosition> selectedCells = FXCollections.observableArrayList();
|
||||
|
||||
public MnemonicGridDialog() {
|
||||
DialogPane dialogPane = new MnemonicGridDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
setTitle("Border Wallets Grid");
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("grid.css").toExternalForm());
|
||||
dialogPane.setHeaderText("Load a Border Wallets PDF, or generate a grid from a BIP39 seed.\nThen select 11 or 23 words in a pattern on the grid.\nThe order of selection is important!");
|
||||
javafx.scene.image.Image image = new Image("/image/border-wallets.png");
|
||||
dialogPane.setGraphic(new ImageView(image));
|
||||
|
||||
String[][] emptyWordGrid = new String[128][GRID_COLUMN_COUNT];
|
||||
Grid grid = getGrid(emptyWordGrid);
|
||||
|
||||
spreadsheetView = new SpreadsheetView(grid);
|
||||
spreadsheetView.setId("grid");
|
||||
spreadsheetView.setEditable(false);
|
||||
spreadsheetView.setFixingColumnsAllowed(false);
|
||||
spreadsheetView.setFixingRowsAllowed(false);
|
||||
spreadsheetView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
|
||||
|
||||
spreadsheetView.getSelectionModel().getSelectedCells().addListener(new ListChangeListener<>() {
|
||||
@Override
|
||||
public void onChanged(Change<? extends TablePosition> c) {
|
||||
while(c.next()) {
|
||||
if(c.wasAdded()) {
|
||||
for(TablePosition<?, ?> pos : c.getAddedSubList()) {
|
||||
if(selectedCells.contains(pos)) {
|
||||
selectedCells.remove(pos);
|
||||
} else {
|
||||
selectedCells.add(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int numWords = selectedCells.size();
|
||||
wordsSelectedProperty.set(numWords == 11 || numWords == 23);
|
||||
}
|
||||
});
|
||||
|
||||
selectedCells.addListener((ListChangeListener<? super TablePosition>) c -> {
|
||||
while(c.next()) {
|
||||
if(c.wasRemoved()) {
|
||||
for(TablePosition<?,?> pos : c.getRemoved()) {
|
||||
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
|
||||
cell.getStyleClass().remove("selection");
|
||||
cell.setGraphic(null);
|
||||
}
|
||||
}
|
||||
if(c.wasAdded()) {
|
||||
for(TablePosition<?,?> pos : c.getAddedSubList()) {
|
||||
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(pos.getRow()).get(pos.getColumn());
|
||||
cell.getStyleClass().add("selection");
|
||||
}
|
||||
}
|
||||
for(int i = 0; i < selectedCells.size(); i++) {
|
||||
Text index = new Text(Integer.toString(i+1));
|
||||
index.setFont(Font.font(8));
|
||||
SpreadsheetCell cell = spreadsheetView.getGrid().getRows().get(selectedCells.get(i).getRow()).get(selectedCells.get(i).getColumn());
|
||||
cell.setGraphic(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().add(spreadsheetView);
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
stackPane.widthProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue != null) {
|
||||
for(SpreadsheetColumn column : spreadsheetView.getColumns()) {
|
||||
column.setPrefWidth((newValue.doubleValue() - spreadsheetView.getRowHeaderWidth() - 3) / 17);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load PDF...", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(loadCsvButtonType);
|
||||
|
||||
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate Grid...", ButtonBar.ButtonData.HELP_2);
|
||||
dialogPane.getButtonTypes().add(generateButtonType);
|
||||
|
||||
final ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear Selection", ButtonBar.ButtonData.OTHER);
|
||||
dialogPane.getButtonTypes().add(clearButtonType);
|
||||
|
||||
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
|
||||
okButton.disableProperty().bind(Bindings.not(Bindings.and(initializedProperty, wordsSelectedProperty)));
|
||||
|
||||
setResultConverter((dialogButton) -> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
return data == ButtonBar.ButtonData.OK_DONE ? getSelectedWords() : null;
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(952);
|
||||
dialogPane.setPrefHeight(500);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
private Grid getGrid(String[][] wordGrid) {
|
||||
int rowCount = wordGrid.length;
|
||||
int columnCount = wordGrid[0].length;
|
||||
GridBase grid = new GridBase(rowCount, columnCount);
|
||||
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
|
||||
grid.getColumnHeaders().setAll("A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P");
|
||||
for(int i = 0; i < rowCount; i++) {
|
||||
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
|
||||
for(int j = 0; j < columnCount; j++) {
|
||||
list.add(createCell(i, j, wordGrid[i][j]));
|
||||
}
|
||||
rows.add(list);
|
||||
grid.getRowHeaders().add(String.format("%03d", i + 1));
|
||||
}
|
||||
grid.setRows(rows);
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
private SpreadsheetCell createCell(int row, int column, String word) {
|
||||
return SpreadsheetCellType.STRING.createCell(row, column, 1, 1, word == null ? "" : word);
|
||||
}
|
||||
|
||||
private List<String> getSelectedWords() {
|
||||
List<String> abbreviations = selectedCells.stream()
|
||||
.map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList());
|
||||
|
||||
boolean isInteger = abbreviations.stream().allMatch(Utils::isNumber);
|
||||
boolean isHex = abbreviations.stream().allMatch(Utils::isHex);
|
||||
|
||||
List<String> words = new ArrayList<>();
|
||||
for(String abbreviation : abbreviations) {
|
||||
if(isInteger) {
|
||||
try {
|
||||
int index = Integer.parseInt(abbreviation);
|
||||
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
} else if(isHex) {
|
||||
try {
|
||||
int index = Integer.parseInt(abbreviation, 16);
|
||||
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
} else {
|
||||
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
|
||||
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
|
||||
words.add(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(words.size() != abbreviations.size()) {
|
||||
abbreviations.removeIf(abbr -> words.stream().anyMatch(w -> w.startsWith(abbr)));
|
||||
throw new IllegalStateException("Could not find words for abbreviations: " + abbreviations);
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
public List<String> shuffle(List<String> mnemonic) {
|
||||
String mnemonicString = String.join(" ", mnemonic);
|
||||
List<String> words = new ArrayList<>(Bip39MnemonicCode.INSTANCE.getWordList());
|
||||
|
||||
UltraHighEntropyPrng uhePrng = new UltraHighEntropyPrng();
|
||||
uhePrng.initState();
|
||||
uhePrng.hashString(mnemonicString);
|
||||
for(int i = words.size() - 1; i > 0; i--) {
|
||||
int j = (int)uhePrng.random(i + 1);
|
||||
String tmp = words.get(i);
|
||||
words.set(i, words.get(j));
|
||||
words.set(j, tmp);
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
private String[][] toGrid(List<String> words) {
|
||||
String[][] grid = new String[words.size()/GRID_COLUMN_COUNT][GRID_COLUMN_COUNT];
|
||||
|
||||
int row = 0;
|
||||
int col = 0;
|
||||
for(String word : words) {
|
||||
String abbr = word.length() < 4 ? word : word.substring(0, 4);
|
||||
grid[row][col] = abbr;
|
||||
col++;
|
||||
if(col >= GRID_COLUMN_COUNT) {
|
||||
col = 0;
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
private class MnemonicGridDialogPane extends DialogPane {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
Node button;
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
Button loadButton = new Button(buttonType.getText());
|
||||
loadButton.setGraphicTextGap(5);
|
||||
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(loadButton, buttonData);
|
||||
loadButton.setOnAction(event -> {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open PDF");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("PDF", "*.pdf")
|
||||
);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450);
|
||||
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
|
||||
if(file != null) {
|
||||
try(BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
|
||||
String[][] wordGrid = PdfUtils.getWordGrid(inputStream);
|
||||
spreadsheetView.setGrid(getGrid(wordGrid));
|
||||
selectedCells.clear();
|
||||
spreadsheetView.getSelectionModel().clearSelection();
|
||||
initializedProperty.set(true);
|
||||
} catch(Exception e) {
|
||||
AppServices.showErrorDialog("Cannot load PDF", e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
button = loadButton;
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
|
||||
Button generateButton = new Button(buttonType.getText());
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(generateButton, buttonData);
|
||||
generateButton.setOnAction(event -> {
|
||||
SeedEntryDialog seedEntryDialog = new SeedEntryDialog("Border Wallets Entropy Grid Recovery Seed", 12);
|
||||
seedEntryDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
List<String> mnemonicWords = optWords.get();
|
||||
List<String> shuffledWordList = shuffle(mnemonicWords);
|
||||
String[][] wordGrid = toGrid(shuffledWordList);
|
||||
spreadsheetView.setGrid(getGrid(wordGrid));
|
||||
selectedCells.clear();
|
||||
spreadsheetView.getSelectionModel().clearSelection();
|
||||
initializedProperty.set(true);
|
||||
|
||||
if(seedEntryDialog.isGenerated()) {
|
||||
PdfUtils.saveWordGrid(wordGrid, mnemonicWords);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
button = generateButton;
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OTHER) {
|
||||
Button clearButton = new Button(buttonType.getText());
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(clearButton, buttonData);
|
||||
clearButton.setOnAction(event -> {
|
||||
selectedCells.clear();
|
||||
spreadsheetView.getSelectionModel().clearSelection();
|
||||
});
|
||||
|
||||
button = clearButton;
|
||||
} else {
|
||||
button = super.createButton(buttonType);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
}
|
||||
|
||||
public static class UltraHighEntropyPrng {
|
||||
private final int order;
|
||||
private double carry;
|
||||
private int phase;
|
||||
private final double[] intermediates;
|
||||
private int i, j, k; // general purpose locals
|
||||
|
||||
public UltraHighEntropyPrng() {
|
||||
order = 48; // set the 'order' number of ENTROPY-holding 32-bit values
|
||||
carry = 1; // init the 'carry' used by the multiply-with-carry (MWC) algorithm
|
||||
phase = order; // init the 'phase' (max-1) of the intermediate variable pointer
|
||||
intermediates = new double[order]; // declare our intermediate variables array
|
||||
|
||||
for(i = 0; i < order; i++) {
|
||||
// Used to simulate javascript's Math.random
|
||||
Random random = new Random();
|
||||
intermediates[i] = mash(random.nextInt(Integer.MAX_VALUE)); // fill the array with initial mash hash values
|
||||
}
|
||||
}
|
||||
|
||||
public double random(int range) {
|
||||
return Math.floor(range * (rawPrng() + ((long)(rawPrng() * 0x200000L)) * 1.1102230246251565e-16)); // 2^-53
|
||||
}
|
||||
|
||||
private String randomString(int count) {
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
for(i = 0; i < count; i++) {
|
||||
char newChar = (char) (33 + random(94));
|
||||
stringBuilder.append(newChar);
|
||||
}
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
|
||||
private double rawPrng() {
|
||||
if(++phase >= order) {
|
||||
phase = 0;
|
||||
}
|
||||
double t = 1768863 * intermediates[phase] + carry * 2.3283064365386963e-10; // 2^-32
|
||||
long temp = (long)t;
|
||||
return intermediates[phase] = t - (carry = temp);
|
||||
}
|
||||
|
||||
private void hash(String args) {
|
||||
for(i = 0; i < args.length(); i++) {
|
||||
for(j = 0; j < order; j++) {
|
||||
intermediates[j] -= mash(args.charAt(i));
|
||||
if(intermediates[j] < 0) {
|
||||
intermediates[j] = intermediates[j] + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void hashString(String input) {
|
||||
mash(input); // use the string to evolve the 'mash' state
|
||||
|
||||
char[] inputAry = input.toCharArray();
|
||||
for(i = 0; i < inputAry.length; i++) // scan through the characters in our string
|
||||
{
|
||||
k = inputAry[i]; // get the character code at the location
|
||||
for(j = 0; j < order; j++) // "mash" it into the UHEPRNG state
|
||||
{
|
||||
intermediates[j] -= mash(k);
|
||||
if(intermediates[j] < 0) {
|
||||
intermediates[j] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void initState() {
|
||||
mash(null);
|
||||
for(i = 0; i < order; i++) {
|
||||
intermediates[i] = mash(' '); // fill the array with initial mash hash values
|
||||
}
|
||||
carry = 1; // init our multiply-with-carry carry
|
||||
phase = order; // init our phase
|
||||
}
|
||||
|
||||
double n = Integer.toUnsignedLong(0xefc8249d);
|
||||
|
||||
private double mash(Object data) {
|
||||
if(data != null) {
|
||||
String strData = data.toString();
|
||||
for(int i = 0; i < strData.length(); i++) {
|
||||
n += strData.charAt(i);
|
||||
double h = 0.02519603282416938 * n;
|
||||
n = Integer.toUnsignedLong((int)h);
|
||||
h -= n;
|
||||
h *= n;
|
||||
n = Integer.toUnsignedLong((int)h);
|
||||
h -= n;
|
||||
n += h * 0x100000000L; // 2^32
|
||||
}
|
||||
return ((long)n) * 2.3283064365386963e-10; // 2^-32
|
||||
} else {
|
||||
n = Integer.toUnsignedLong(0xefc8249d);
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
@@ -25,7 +24,7 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Node getMnemonicWordsEntry(int numWords, boolean editPassphrase) {
|
||||
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(), false));
|
||||
setContent(getMnemonicWordsEntry(wordEntriesProperty.get().size(), true, false));
|
||||
setExpanded(true);
|
||||
backButton.setVisible(true);
|
||||
generateButton.setVisible(false);
|
||||
@@ -254,6 +254,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
|
||||
if(!dryrun) {
|
||||
if(passphraseProperty.get() != null && !passphraseProperty.get().isEmpty()) {
|
||||
KeystorePassphraseDialog keystorePassphraseDialog = new KeystorePassphraseDialog(null, keystore, true);
|
||||
keystorePassphraseDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<String> optPassphrase = keystorePassphraseDialog.showAndWait();
|
||||
if(optPassphrase.isEmpty() || !optPassphrase.get().equals(passphraseProperty.get())) {
|
||||
throw new ImportException("Re-entered passphrase did not match");
|
||||
|
||||
@@ -12,6 +12,8 @@ import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Orientation;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -20,8 +22,8 @@ import javafx.scene.control.*;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.util.Callback;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.control.textfield.AutoCompletionBinding;
|
||||
import org.controlsfx.control.textfield.CustomTextField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
@@ -44,7 +46,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
protected Label invalidLabel;
|
||||
|
||||
protected SimpleListProperty<String> wordEntriesProperty;
|
||||
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
|
||||
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty("");
|
||||
protected IntegerProperty defaultWordSizeProperty;
|
||||
|
||||
public MnemonicKeystorePane(String title, String description, String content, String imageUrl) {
|
||||
@@ -79,6 +81,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
enterMnemonicButton.getItems().add(item);
|
||||
}
|
||||
enterMnemonicButton.getItems().add(new SeparatorMenuItem());
|
||||
MenuItem gridItem = new MenuItem("Border Wallets Grid...");
|
||||
gridItem.setOnAction(event -> {
|
||||
showGrid();
|
||||
});
|
||||
enterMnemonicButton.getItems().add(gridItem);
|
||||
|
||||
MenuItem scanItem = new MenuItem("Scan QR...");
|
||||
scanItem.setOnAction(event -> {
|
||||
scanQR();
|
||||
@@ -87,8 +95,47 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
|
||||
}
|
||||
|
||||
protected void showGrid() {
|
||||
MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog();
|
||||
mnemonicGridDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<List<String>> optWords = mnemonicGridDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
List<String> words = optWords.get();
|
||||
defaultWordSizeProperty.set(words.size() + 1);
|
||||
setContent(getMnemonicWordsEntry(words.size() + 1, true, true));
|
||||
setExpanded(true);
|
||||
|
||||
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
|
||||
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
|
||||
if(i < words.size()) {
|
||||
wordEntry.getEditor().setText(words.get(i));
|
||||
wordEntry.getEditor().setEditable(false);
|
||||
} else {
|
||||
ScheduledService<Void> service = new ScheduledService<>() {
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected Void call() {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
service.setDelay(Duration.millis(500));
|
||||
service.setOnSucceeded(event1 -> {
|
||||
service.cancel();
|
||||
Platform.runLater(() -> wordEntry.getEditor().requestFocus());
|
||||
});
|
||||
service.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void scanQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
@@ -106,7 +153,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
|
||||
protected void showWordList(DeterministicSeed seed) {
|
||||
List<String> words = seed.getMnemonicCode();
|
||||
setContent(getMnemonicWordsEntry(words.size(), true));
|
||||
setContent(getMnemonicWordsEntry(words.size(), true, true));
|
||||
setExpanded(true);
|
||||
|
||||
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
|
||||
@@ -119,11 +166,11 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
protected void enterMnemonic(int numWords) {
|
||||
setDescription("Generate new or enter existing");
|
||||
showHideLink.setVisible(false);
|
||||
setContent(getMnemonicWordsEntry(numWords, true));
|
||||
setContent(getMnemonicWordsEntry(numWords, true, true));
|
||||
setExpanded(true);
|
||||
}
|
||||
|
||||
protected Node getMnemonicWordsEntry(int numWords, boolean editPassphrase) {
|
||||
protected Node getMnemonicWordsEntry(int numWords, boolean showPassphrase, boolean editPassphrase) {
|
||||
VBox vBox = new VBox();
|
||||
vBox.setSpacing(10);
|
||||
|
||||
@@ -152,10 +199,12 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
|
||||
vBox.getChildren().add(wordsPane);
|
||||
|
||||
PassphraseEntry passphraseEntry = new PassphraseEntry(editPassphrase);
|
||||
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
|
||||
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
|
||||
vBox.getChildren().add(passphraseEntry);
|
||||
if(showPassphrase) {
|
||||
PassphraseEntry passphraseEntry = new PassphraseEntry(editPassphrase);
|
||||
wordEntries.get(wordEntries.size() - 1).setNextField(passphraseEntry.getEditor());
|
||||
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
|
||||
vBox.getChildren().add(passphraseEntry);
|
||||
}
|
||||
|
||||
AnchorPane buttonPane = new AnchorPane();
|
||||
buttonPane.setPadding(new Insets(0, 26, 0, 10));
|
||||
@@ -250,9 +299,14 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
if(clipboard.hasString() && clipboard.getString().matches("(?m).+[\\n\\s][\\S\\s]*")) {
|
||||
String[] words = clipboard.getString().split("[\\n\\s]");
|
||||
WordEntry entry = WordEntry.this;
|
||||
for(String word : words) {
|
||||
for(int i = 0; i < words.length; i++) {
|
||||
String word = words[i];
|
||||
if(entry.nextField != null) {
|
||||
entry.nextField.requestFocus();
|
||||
if(i == words.length - 2 && isValid(word)) {
|
||||
label.requestFocus();
|
||||
} else {
|
||||
entry.nextField.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
entry.wordField.setText(word);
|
||||
@@ -282,7 +336,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
wordField.setTextFormatter(formatter);
|
||||
|
||||
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
|
||||
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
|
||||
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList, wordNumber, wordEntryList));
|
||||
autoCompletionBinding.setDelay(50);
|
||||
autoCompletionBinding.setOnAutoCompleted(event -> {
|
||||
if(nextField != null) {
|
||||
@@ -290,11 +344,20 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
}
|
||||
});
|
||||
|
||||
//Show autocomplete for the last word on focus if empty
|
||||
boolean lastWord = wordNumber == wordEntryList.size() - 1;
|
||||
if(lastWord) {
|
||||
wordField.focusedProperty().addListener((observable, oldValue, focused) -> {
|
||||
if(focused && wordField.getText().isEmpty()) {
|
||||
autoCompletionBinding.setUserInput("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.registerValidator(wordField, Validator.combine(
|
||||
Validator.createEmptyValidator("Word is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", (newValue.length() > 0 || !lastWord) && !wordList.contains(newValue))
|
||||
));
|
||||
|
||||
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
@@ -323,13 +386,30 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
|
||||
protected static class WordlistSuggestionProvider implements Callback<AutoCompletionBinding.ISuggestionRequest, Collection<String>> {
|
||||
private final List<String> wordList;
|
||||
private final int wordNumber;
|
||||
private final ObservableList<String> wordEntryList;
|
||||
|
||||
public WordlistSuggestionProvider(List<String> wordList) {
|
||||
public WordlistSuggestionProvider(List<String> wordList, int wordNumber, ObservableList<String> wordEntryList) {
|
||||
this.wordList = wordList;
|
||||
this.wordNumber = wordNumber;
|
||||
this.wordEntryList = wordEntryList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<String> call(AutoCompletionBinding.ISuggestionRequest request) {
|
||||
if(wordNumber == wordEntryList.size() - 1 && allPreviousWordsValid()) {
|
||||
try {
|
||||
List<String> possibleLastWords = Bip39MnemonicCode.INSTANCE.getPossibleLastWords(wordEntryList.subList(0, wordEntryList.size() - 1));
|
||||
if(!request.getUserText().isEmpty()) {
|
||||
possibleLastWords.removeIf(s -> !s.startsWith(request.getUserText()));
|
||||
}
|
||||
|
||||
return possibleLastWords;
|
||||
} catch(Exception e) {
|
||||
log.warn("Cannot determine possible last words", e);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> suggestions = new ArrayList<>();
|
||||
if(!request.getUserText().isEmpty()) {
|
||||
for(String word : wordList) {
|
||||
@@ -341,6 +421,16 @@ 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 {
|
||||
|
||||
@@ -109,6 +109,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
|
||||
|
||||
List<List<ChildNumber>> derivations = ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).stream().map(ScriptType::getDefaultDerivation).collect(Collectors.toList());
|
||||
derivations.add(List.of(new ChildNumber(0, true)));
|
||||
derivations.add(ScriptType.P2PKH.getDefaultDerivation(1)); //Bisq segwit misderivation
|
||||
|
||||
for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE)) {
|
||||
for(List<ChildNumber> derivation : derivations) {
|
||||
|
||||
@@ -110,6 +110,7 @@ public class PayNymCell extends ListCell<PayNym> {
|
||||
MenuItem rename = new MenuItem("Rename Contact...");
|
||||
rename.setOnAction(event -> {
|
||||
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
|
||||
walletLabelDialog.initOwner(PayNymCell.this.getScene().getWindow());
|
||||
Optional<String> optLabel = walletLabelDialog.showAndWait();
|
||||
if(optLabel.isPresent()) {
|
||||
int index = getListView().getItems().indexOf(payNym);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.base.Throwables;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.google.common.io.Files;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
@@ -14,6 +15,9 @@ import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.CardApi;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
@@ -46,10 +50,7 @@ import tornadofx.control.Form;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
|
||||
@@ -62,6 +63,8 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
private final CopyableLabel keyAddress;
|
||||
private final ComboBoxTextField toAddress;
|
||||
private final ComboBox<Wallet> toWallet;
|
||||
private final FeeRangeSlider feeRange;
|
||||
private final CopyableLabel feeRate;
|
||||
|
||||
public PrivateKeySweepDialog(Wallet wallet) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
@@ -146,7 +149,24 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
stackPane.getChildren().addAll(toWallet, toAddress);
|
||||
toAddressField.getInputs().add(stackPane);
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField);
|
||||
Field feeRangeField = new Field();
|
||||
feeRangeField.setText("Fee range:");
|
||||
feeRange = new FeeRangeSlider();
|
||||
feeRange.setMaxWidth(320);
|
||||
feeRangeField.getInputs().add(feeRange);
|
||||
|
||||
Field feeRateField = new Field();
|
||||
feeRateField.setText("Fee rate:");
|
||||
feeRate = new CopyableLabel();
|
||||
feeRateField.getInputs().add(feeRate);
|
||||
|
||||
feeRange.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
updateFeeRate();
|
||||
});
|
||||
feeRange.setFeeRate(AppServices.getDefaultFeeRate());
|
||||
updateFeeRate();
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField, feeRangeField, feeRateField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
|
||||
@@ -201,6 +221,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
setResultConverter(dialogButton -> null);
|
||||
dialogPane.setPrefWidth(680);
|
||||
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater(() -> {
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
@@ -224,6 +249,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
private void decryptKey() {
|
||||
PassphraseDialog passphraseDialog = new PassphraseDialog();
|
||||
passphraseDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<String> optPassphrase = passphraseDialog.showAndWait();
|
||||
if(optPassphrase.isPresent()) {
|
||||
try {
|
||||
@@ -265,6 +291,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
private void scanPrivateKey() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
|
||||
if(result.isPresent() && result.get().payload != null) {
|
||||
key.setText(result.get().payload);
|
||||
@@ -294,6 +321,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
private void unsealPrivateKey() {
|
||||
DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList());
|
||||
deviceUnsealDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<DeviceUnsealDialog.DevicePrivateKey> optPrivateKey = deviceUnsealDialog.showAndWait();
|
||||
if(optPrivateKey.isPresent()) {
|
||||
DeviceUnsealDialog.DevicePrivateKey devicePrivateKey = optPrivateKey.get();
|
||||
@@ -312,6 +340,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
Date since = null;
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
|
||||
WalletBirthDateDialog addressScanDateDialog = new WalletBirthDateDialog(null, true);
|
||||
addressScanDateDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<Date> optSince = addressScanDateDialog.showAndWait();
|
||||
if(optSince.isPresent()) {
|
||||
since = optSince.get();
|
||||
@@ -330,6 +359,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
|
||||
ServiceProgressDialog serviceProgressDialog = new ServiceProgressDialog("Address Scan", "Scanning address for transactions...", "/image/sparrow.png", addressUtxosService);
|
||||
serviceProgressDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
|
||||
}
|
||||
|
||||
@@ -352,7 +382,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
|
||||
noFeeTransaction.addOutput(sweepOutput);
|
||||
|
||||
Double feeRate = AppServices.getDefaultFeeRate();
|
||||
double feeRate = feeRange.getFeeRate();
|
||||
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
|
||||
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||
fee++;
|
||||
@@ -378,11 +408,17 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
|
||||
|
||||
PSBT psbt = new PSBT(transaction);
|
||||
//Set witness UTXOs on PSBT inputs first - they are all required when hashing for a Taproot signature
|
||||
for(int i = 0; i < txOutputs.size(); i++) {
|
||||
TransactionOutput utxoOutput = txOutputs.get(i);
|
||||
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
|
||||
psbtInput.setWitnessUtxo(utxoOutput);
|
||||
}
|
||||
|
||||
for(int i = 0; i < txOutputs.size(); i++) {
|
||||
TransactionOutput utxoOutput = txOutputs.get(i);
|
||||
TransactionInput txInput = transaction.getInputs().get(i);
|
||||
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
|
||||
psbtInput.setWitnessUtxo(utxoOutput);
|
||||
|
||||
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
|
||||
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
|
||||
@@ -414,6 +450,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
return glyph;
|
||||
}
|
||||
|
||||
private void updateFeeRate() {
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
feeRate.setText(format.getCurrencyFormat().format(feeRange.getFeeRate()) + " sats/vB");
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
|
||||
feeRange.updateTrackHighlight();
|
||||
}
|
||||
|
||||
public class PassphraseDialog extends Dialog<String> {
|
||||
private final CustomPasswordField passphrase;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
public enum QRDensity {
|
||||
NORMAL("Normal", 250),
|
||||
NORMAL("Normal", 400),
|
||||
LOW("Low", 80);
|
||||
|
||||
private final String name;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.client.j2se.MatrixToImageConfig;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
@@ -29,18 +30,21 @@ import org.slf4j.LoggerFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class QRDisplayDialog extends Dialog<UR> {
|
||||
public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
|
||||
|
||||
private static final int MIN_FRAGMENT_LENGTH = 10;
|
||||
|
||||
private static final int ANIMATION_PERIOD_MILLIS = 200;
|
||||
|
||||
private static final int QR_WIDTH = 480;
|
||||
private static final int QR_HEIGHT = 480;
|
||||
private static final int DEFAULT_QR_SIZE = 580;
|
||||
private static final int REDUCED_QR_SIZE = 520;
|
||||
|
||||
private final int qrSize = getQRSize();
|
||||
|
||||
private final UR ur;
|
||||
private UREncoder encoder;
|
||||
@@ -58,14 +62,14 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
private static boolean initialDensityChange;
|
||||
|
||||
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
|
||||
this(UR.fromBytes(type, data), addLegacyEncodingOption);
|
||||
this(UR.fromBytes(type, data), addLegacyEncodingOption, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur) {
|
||||
this(ur, false);
|
||||
this(ur, false, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) {
|
||||
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption, boolean addScanButton) {
|
||||
this.ur = ur;
|
||||
this.addLegacyEncodingOption = addLegacyEncodingOption;
|
||||
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
|
||||
@@ -98,18 +102,29 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
dialogPane.getButtonTypes().add(densityButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
|
||||
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
|
||||
if(addScanButton) {
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + qrSize + 40);
|
||||
dialogPane.setPrefHeight(40 + qrSize + 85);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
setResultConverter(dialogButton -> dialogButton);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(String data) {
|
||||
this(data, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(String data, boolean addScanButton) {
|
||||
this.ur = null;
|
||||
this.encoder = null;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
@@ -121,11 +136,22 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
|
||||
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
|
||||
|
||||
if(addScanButton) {
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + qrSize + 40);
|
||||
dialogPane.setPrefHeight(40 + qrSize + 85);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
setResultConverter(dialogButton -> dialogButton);
|
||||
}
|
||||
|
||||
private int getQRSize() {
|
||||
return AppServices.isReducedWindowHeight() ? REDUCED_QR_SIZE : DEFAULT_QR_SIZE;
|
||||
}
|
||||
|
||||
private void createAnimateQRService() {
|
||||
@@ -153,7 +179,7 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
protected Image getQrCode(String fragment) {
|
||||
try {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, qrSize, qrSize, Map.of(EncodeHintType.MARGIN, "2"));
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
|
||||
@@ -280,6 +306,12 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
return density;
|
||||
}
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
Button scanButton = (Button)super.createButton(buttonType);
|
||||
scanButton.setGraphicTextGap(5);
|
||||
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
|
||||
|
||||
return scanButton;
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
|
||||
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
|
||||
import com.sparrowwallet.drongo.wallet.SeedQR;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
@@ -24,6 +25,8 @@ import com.sparrowwallet.hummingbird.registry.*;
|
||||
import com.sparrowwallet.hummingbird.ResultType;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.URDecoder;
|
||||
import com.sparrowwallet.hummingbird.registry.pathcomponent.IndexPathComponent;
|
||||
import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
@@ -170,6 +173,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
|
||||
dialogPane.setPrefWidth(646);
|
||||
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
|
||||
@@ -207,6 +211,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
URDecoder.Result urResult = decoder.getResult();
|
||||
if(urResult.type == ResultType.SUCCESS) {
|
||||
result = extractResultFromUR(urResult.ur);
|
||||
Platform.runLater(() -> setResult(result));
|
||||
} else {
|
||||
result = new Result(new URException(urResult.error));
|
||||
}
|
||||
@@ -260,7 +265,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
DeterministicSeed seed;
|
||||
try {
|
||||
extendedKey = ExtendedKey.fromDescriptor(qrtext);
|
||||
result = new Result(extendedKey);
|
||||
result = new Result(extendedKey, null);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not a valid xpub
|
||||
@@ -351,6 +356,17 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
//Ignore, not parseable as a CompactSeedQR
|
||||
}
|
||||
|
||||
try {
|
||||
List<String> words = Arrays.asList(qrtext.split(" "));
|
||||
if(words.size() == 12 || words.size() == 15 || words.size() == 18 || words.size() == 21 || words.size() == 24) {
|
||||
Bip39MnemonicCode.INSTANCE.check(words);
|
||||
result = new Result(new DeterministicSeed(words, null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39));
|
||||
return;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as BIP39 seed words
|
||||
}
|
||||
|
||||
result = new Result(qrtext);
|
||||
}
|
||||
}
|
||||
@@ -409,7 +425,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
|
||||
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
return new Result(extendedKey);
|
||||
return new Result(extendedKey, cryptoHDKey.getName());
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
|
||||
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
|
||||
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
|
||||
@@ -467,7 +483,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
if(cryptoHDKey.getOrigin() != null) {
|
||||
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
|
||||
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
|
||||
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened());
|
||||
if(lastComponent instanceof IndexPathComponent indexPathComponent) {
|
||||
lastChild = new ChildNumber(indexPathComponent.getIndex(), indexPathComponent.isHardened());
|
||||
}
|
||||
depth = cryptoHDKey.getOrigin().getComponents().size();
|
||||
}
|
||||
if(cryptoHDKey.getParentFingerprint() != null) {
|
||||
@@ -485,18 +503,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
if(cryptoOutput.getMultiKey() != null) {
|
||||
MultiKey multiKey = cryptoOutput.getMultiKey();
|
||||
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
|
||||
Map<ExtendedKey, String> extendedPublicKeyLabels = new LinkedHashMap<>();
|
||||
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
|
||||
extendedPublicKeys.put(extendedKey, keyDerivation);
|
||||
if(cryptoHDKey.getName() != null) {
|
||||
extendedPublicKeyLabels.put(extendedKey, cryptoHDKey.getName());
|
||||
}
|
||||
}
|
||||
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys);
|
||||
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys, new LinkedHashMap<>(), extendedPublicKeyLabels);
|
||||
} else if(cryptoOutput.getEcKey() != null) {
|
||||
throw new IllegalArgumentException("EC keys are currently unsupported");
|
||||
} else if(cryptoOutput.getHdKey() != null) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
|
||||
return new OutputDescriptor(scriptType, extendedKey, keyDerivation);
|
||||
return new OutputDescriptor(scriptType, extendedKey, keyDerivation, cryptoOutput.getHdKey().getName());
|
||||
}
|
||||
|
||||
throw new IllegalStateException("CryptoOutput did not contain sufficient information");
|
||||
@@ -543,11 +565,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
|
||||
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
|
||||
if(cryptoKeypath != null) {
|
||||
if(cryptoKeypath.getComponents().stream().anyMatch(PathComponent::isWildcard)) {
|
||||
throw new IllegalArgumentException("Wildcard derivation paths are not supported");
|
||||
if(!cryptoKeypath.getComponents().stream().allMatch(pathComponent -> pathComponent instanceof IndexPathComponent)) {
|
||||
throw new IllegalArgumentException("Only indexed derivation path components are supported");
|
||||
}
|
||||
|
||||
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
|
||||
List<ChildNumber> path = cryptoKeypath.getComponents().stream().map(comp -> (IndexPathComponent)comp)
|
||||
.map(comp -> new ChildNumber(comp.getIndex(), comp.isHardened())).collect(Collectors.toList());
|
||||
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), KeyDerivation.writePath(path));
|
||||
}
|
||||
|
||||
@@ -662,6 +685,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
public final PSBT psbt;
|
||||
public final BitcoinURI uri;
|
||||
public final ExtendedKey extendedKey;
|
||||
public final String extendedKeyName;
|
||||
public final OutputDescriptor outputDescriptor;
|
||||
public final List<Wallet> wallets;
|
||||
public final DeterministicSeed seed;
|
||||
@@ -673,6 +697,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -685,6 +710,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = psbt;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -697,6 +723,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = uri;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -709,6 +736,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = BitcoinURI.fromAddress(address);
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -716,11 +744,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(ExtendedKey extendedKey) {
|
||||
public Result(ExtendedKey extendedKey, String name) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = extendedKey;
|
||||
this.extendedKeyName = name;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -733,6 +762,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -745,6 +775,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = wallets;
|
||||
this.seed = null;
|
||||
@@ -757,6 +788,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = seed;
|
||||
@@ -769,6 +801,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
@@ -781,6 +814,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.extendedKeyName = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.seed = null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
@@ -21,6 +23,7 @@ import tornadofx.control.Form;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public class SearchWalletDialog extends Dialog<Entry> {
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
|
||||
@@ -36,13 +39,16 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
throw new IllegalArgumentException("No wallets selected to search");
|
||||
}
|
||||
|
||||
boolean showWallet = walletForms.stream().map(WalletForm::getMasterWallet).distinct().limit(2).count() > 1;
|
||||
boolean showAccount = walletForms.stream().anyMatch(walletForm -> !walletForm.getWallet().isMasterWallet() || !walletForm.getNestedWalletForms().isEmpty());
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText("Search Wallet");
|
||||
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet");
|
||||
|
||||
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
|
||||
if(!image.isError()) {
|
||||
@@ -69,11 +75,9 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
fieldset.getChildren().addAll(searchField);
|
||||
form.getChildren().add(fieldset);
|
||||
|
||||
boolean showWallet = walletForms.size() > 1 || walletForms.stream().anyMatch(walletForm -> !walletForm.getNestedWalletForms().isEmpty());
|
||||
|
||||
results = new CoinTreeTable();
|
||||
results.setShowRoot(false);
|
||||
results.setPrefWidth(showWallet ? 950 : 850);
|
||||
results.setPrefWidth(showWallet || showAccount ? 950 : 850);
|
||||
results.setUnitFormat(walletForms.iterator().next().getWallet());
|
||||
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
results.setPlaceholder(new Label("No results"));
|
||||
@@ -81,11 +85,19 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
if(showWallet) {
|
||||
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
|
||||
walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getMasterName());
|
||||
});
|
||||
results.getColumns().add(walletColumn);
|
||||
}
|
||||
|
||||
if(showAccount) {
|
||||
TreeTableColumn<Entry, String> accountColumn = new TreeTableColumn<>("Account");
|
||||
accountColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
|
||||
});
|
||||
results.getColumns().add(accountColumn);
|
||||
}
|
||||
|
||||
TreeTableColumn<Entry, String> typeColumn = new TreeTableColumn<>("Type");
|
||||
typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType());
|
||||
@@ -135,25 +147,22 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
});
|
||||
|
||||
search.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
searchWallet(newValue.toLowerCase(Locale.ROOT));
|
||||
searchWallets(newValue.toLowerCase(Locale.ROOT));
|
||||
});
|
||||
|
||||
setResizable(true);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Platform.runLater(search::requestFocus);
|
||||
}
|
||||
|
||||
private void searchWallet(String searchText) {
|
||||
private void searchWallets(String searchText) {
|
||||
List<Entry> matchingEntries = new ArrayList<>();
|
||||
|
||||
if(!searchText.isEmpty()) {
|
||||
Long searchValue = null;
|
||||
|
||||
try {
|
||||
searchValue = Math.abs(Long.parseLong(searchText));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
Long searchValue = getSearchValue(searchText);
|
||||
Address searchAddress = getSearchAddress(searchText);
|
||||
|
||||
for(WalletForm walletForm : walletForms) {
|
||||
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
|
||||
@@ -161,7 +170,8 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
if(entry instanceof TransactionEntry transactionEntry) {
|
||||
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
|
||||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
|
||||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue)) {
|
||||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue) ||
|
||||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
|
||||
matchingEntries.add(entry);
|
||||
}
|
||||
}
|
||||
@@ -213,6 +223,22 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
results.setRoot(rootItem);
|
||||
}
|
||||
|
||||
private Long getSearchValue(String searchText) {
|
||||
try {
|
||||
return Math.abs(Long.parseLong(searchText));
|
||||
} catch(NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Address getSearchAddress(String searchText) {
|
||||
try {
|
||||
return Address.fromString(searchText);
|
||||
} catch(InvalidAddressException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SearchWalletEntry extends Entry {
|
||||
public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
|
||||
super(wallet, wallet.getName(), entries);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SeedEntryDialog extends Dialog<List<String>> {
|
||||
private final MnemonicKeystoreEntryPane keystorePane;
|
||||
|
||||
public SeedEntryDialog(String name, int numWords) {
|
||||
final DialogPane dialogPane = new MnemonicGridDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
int lines = numWords / 3;
|
||||
int height = lines * 40;
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
dialogPane.setContent(stackPane);
|
||||
|
||||
AnchorPane anchorPane = new AnchorPane();
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.getStyleClass().add("edge-to-edge");
|
||||
scrollPane.setPrefHeight(104 + height);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
AnchorPane.setLeftAnchor(scrollPane, 0.0);
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
Accordion keystoreAccordion = new Accordion();
|
||||
scrollPane.setContent(keystoreAccordion);
|
||||
|
||||
keystorePane = new MnemonicKeystoreEntryPane(name, numWords);
|
||||
keystorePane.setAnimated(false);
|
||||
keystoreAccordion.getPanes().add(keystorePane);
|
||||
|
||||
stackPane.getChildren().addAll(anchorPane);
|
||||
|
||||
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||
|
||||
Button okButton = (Button)dialogPane.lookupButton(ButtonType.OK);
|
||||
okButton.disableProperty().bind(keystorePane.validProperty().not());
|
||||
|
||||
final ButtonType generateButtonType = new javafx.scene.control.ButtonType("Generate New", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(generateButtonType);
|
||||
|
||||
setResultConverter((dialogButton) -> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
return data == ButtonBar.ButtonData.OK_DONE ? keystorePane.wordEntriesProperty.get() : null;
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(180 + height);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
|
||||
}
|
||||
|
||||
private class MnemonicGridDialogPane extends DialogPane {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
Node button;
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
Button generateButton = new Button(buttonType.getText());
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(generateButton, buttonData);
|
||||
generateButton.setOnAction(event -> {
|
||||
keystorePane.generateNew();
|
||||
});
|
||||
|
||||
button = generateButton;
|
||||
} else {
|
||||
button = super.createButton(buttonType);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isGenerated() {
|
||||
return keystorePane.isGenerated();
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
|
||||
|
||||
dialogPane.setPrefWidth(850);
|
||||
dialogPane.setPrefHeight(500);
|
||||
setResizable(true);
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.Optional;
|
||||
|
||||
public class TextAreaDialog extends Dialog<String> {
|
||||
@@ -22,6 +25,8 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
|
||||
private final TextArea textArea;
|
||||
private final String defaultValue;
|
||||
private final String fileName;
|
||||
private final byte[] fileBytes;
|
||||
|
||||
public TextAreaDialog() {
|
||||
this("");
|
||||
@@ -31,7 +36,11 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
this(defaultValue, true);
|
||||
}
|
||||
|
||||
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable) {
|
||||
public TextAreaDialog(String defaultValue, boolean editable) {
|
||||
this(defaultValue, editable, null, null);
|
||||
}
|
||||
|
||||
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue, @NamedArg("editable") boolean editable, @NamedArg("fileName") String fileName, @NamedArg("fileBytes") byte[] fileBytes) {
|
||||
final DialogPane dialogPane = new TextAreaDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
|
||||
@@ -48,6 +57,8 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
HBox.setHgrow(this.textArea, Priority.ALWAYS);
|
||||
|
||||
this.defaultValue = defaultValue;
|
||||
this.fileName = fileName;
|
||||
this.fileBytes = fileBytes;
|
||||
|
||||
dialogPane.setContent(hbox);
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
@@ -60,6 +71,9 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
} else if(fileBytes != null) {
|
||||
final ButtonType saveButtonType = new javafx.scene.control.ButtonType("Save File...", ButtonBar.ButtonData.LEFT);
|
||||
dialogPane.getButtonTypes().add(saveButtonType);
|
||||
}
|
||||
|
||||
Platform.runLater(textArea::requestFocus);
|
||||
@@ -71,6 +85,7 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
|
||||
dialogPane.setPrefWidth(700);
|
||||
dialogPane.setPrefHeight(400);
|
||||
setResizable(true);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
@@ -86,7 +101,7 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
@Override
|
||||
protected Node createButton(ButtonType buttonType) {
|
||||
Node button;
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
|
||||
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Scan QR")) {
|
||||
Button scanButton = new Button(buttonType.getText());
|
||||
scanButton.setGraphicTextGap(5);
|
||||
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
|
||||
@@ -95,6 +110,7 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
ButtonBar.setButtonData(scanButton, buttonData);
|
||||
scanButton.setOnAction(event -> {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
@@ -118,6 +134,38 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
});
|
||||
|
||||
button = scanButton;
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT && buttonType.getText().equals("Save File...")) {
|
||||
Button saveButton = new Button(buttonType.getText());
|
||||
|
||||
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
|
||||
ButtonBar.setButtonData(saveButton, buttonData);
|
||||
|
||||
saveButton.setOnAction(event -> {
|
||||
Stage window = new Stage();
|
||||
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Save File");
|
||||
|
||||
if(fileName != null) {
|
||||
fileChooser.setInitialFileName(fileName);
|
||||
}
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showSaveDialog(window);
|
||||
if(file != null) {
|
||||
try {
|
||||
try(OutputStream outputStream = new FileOutputStream(file)) {
|
||||
outputStream.write(fileBytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.error("Error saving file", e);
|
||||
AppServices.showErrorDialog("Error saving file", "Cannot write to " + file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
button = saveButton;
|
||||
} else {
|
||||
button = super.createButton(buttonType);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.concurrent.Worker;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
@@ -33,15 +34,15 @@ public class TorStatusLabel extends Label {
|
||||
}
|
||||
|
||||
public void update() {
|
||||
boolean proxyInUse = AppServices.isUsingProxy();
|
||||
boolean internal = AppServices.isTorRunning();
|
||||
|
||||
if(!proxyInUse || internal) {
|
||||
if(!Config.get().isUseProxy()) {
|
||||
torConnectionTest.cancel();
|
||||
if(internal) {
|
||||
if(AppServices.isTorRunning()) {
|
||||
setTooltip(new Tooltip("Internal Tor proxy enabled"));
|
||||
}
|
||||
} else if(!torConnectionTest.isRunning()) {
|
||||
if(torConnectionTest.getState() == Worker.State.CANCELLED || torConnectionTest.getState() == Worker.State.FAILED) {
|
||||
torConnectionTest.reset();
|
||||
}
|
||||
torConnectionTest.setPeriod(Duration.seconds(20.0));
|
||||
torConnectionTest.setBackoffStrategy(null);
|
||||
torConnectionTest.setOnSucceeded(workerStateEvent -> {
|
||||
|
||||
@@ -72,6 +72,7 @@ public class TransactionDiagram extends GridPane {
|
||||
|
||||
private WalletTransaction walletTx;
|
||||
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
|
||||
private final ObjectProperty<TransactionDiagramLabel> labelProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
|
||||
private boolean expanded;
|
||||
private TransactionDiagram expandedDiagram;
|
||||
@@ -154,6 +155,10 @@ public class TransactionDiagram extends GridPane {
|
||||
updateDerivedDiagram(expandedDiagram);
|
||||
}
|
||||
}
|
||||
|
||||
if(getLabel() != null) {
|
||||
getLabel().update(this);
|
||||
}
|
||||
}
|
||||
|
||||
public void update(String message) {
|
||||
@@ -534,8 +539,9 @@ public class TransactionDiagram extends GridPane {
|
||||
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
|
||||
}
|
||||
|
||||
private String getSatsValue(long amount) {
|
||||
return String.format(Locale.ENGLISH, "%,d", amount);
|
||||
String getSatsValue(long amount) {
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
return format.formatSatsValue(amount);
|
||||
}
|
||||
|
||||
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
|
||||
@@ -636,7 +642,7 @@ public class TransactionDiagram extends GridPane {
|
||||
|
||||
double width = 140.0;
|
||||
long sum = walletTx.getTotal();
|
||||
List<Long> values = walletTx.getTransaction().getOutputs().stream().map(TransactionOutput::getValue).collect(Collectors.toList());
|
||||
List<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
|
||||
values.add(walletTx.getFee());
|
||||
int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
|
||||
for(int i = 1; i <= numOutputs; i++) {
|
||||
@@ -691,7 +697,8 @@ public class TransactionDiagram extends GridPane {
|
||||
Wallet toBip47Wallet = getBip47SendWallet(payment);
|
||||
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
|
||||
+ getSatsValue(payment.getAmount()) + " sats to "
|
||||
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
|
||||
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
|
||||
+ (isDuplicateAddress(payment) ? " (Duplicate)" : ""));
|
||||
recipientTooltip.getStyleClass().add("recipient-label");
|
||||
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
recipientTooltip.setShowDuration(Duration.INDEFINITE);
|
||||
@@ -922,12 +929,12 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
|
||||
if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) {
|
||||
return "Whirlpool Fee";
|
||||
return "Whirlpool fee";
|
||||
} else if(walletTx.isPremixSend(payment)) {
|
||||
int premixIndex = getOutputIndex(payment.getAddress(), payment.getAmount()) - 2;
|
||||
return "Premix #" + premixIndex;
|
||||
} else if(walletTx.isBadbankSend(payment)) {
|
||||
return "Badbank Change";
|
||||
return "Badbank change";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -937,7 +944,7 @@ public class TransactionDiagram extends GridPane {
|
||||
return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private Wallet getToWallet(Payment payment) {
|
||||
Wallet getToWallet(Payment payment) {
|
||||
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
|
||||
if(openWallet != walletTx.getWallet() && openWallet.isValid()) {
|
||||
WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress());
|
||||
@@ -984,11 +991,17 @@ public class TransactionDiagram extends GridPane {
|
||||
return ((AdditionalPayment)payment).getOutputGlyph(this);
|
||||
} else if(getToWallet(payment) != null) {
|
||||
return getDepositGlyph();
|
||||
} else if(isDuplicateAddress(payment)) {
|
||||
return getPaymentWarningGlyph();
|
||||
}
|
||||
|
||||
return getPaymentGlyph();
|
||||
}
|
||||
|
||||
private boolean isDuplicateAddress(Payment payment) {
|
||||
return walletTx.getPayments().stream().filter(p -> payment != p).anyMatch(p -> payment.getAddress() != null && payment.getAddress().equals(p.getAddress()));
|
||||
}
|
||||
|
||||
public static Glyph getExcludeGlyph() {
|
||||
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
|
||||
excludeGlyph.getStyleClass().add("exclude-utxo");
|
||||
@@ -1003,6 +1016,13 @@ public class TransactionDiagram extends GridPane {
|
||||
return paymentGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getPaymentWarningGlyph() {
|
||||
Glyph paymentWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
|
||||
paymentWarningGlyph.getStyleClass().add("payment-warning-icon");
|
||||
paymentWarningGlyph.setFontSize(12);
|
||||
return paymentWarningGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getConsolidationGlyph() {
|
||||
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
|
||||
consolidationGlyph.getStyleClass().add("consolidation-icon");
|
||||
@@ -1077,7 +1097,7 @@ public class TransactionDiagram extends GridPane {
|
||||
return changeReplaceGlyph;
|
||||
}
|
||||
|
||||
private Glyph getFeeGlyph() {
|
||||
public Glyph getFeeGlyph() {
|
||||
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
|
||||
feeGlyph.getStyleClass().add("fee-icon");
|
||||
feeGlyph.setFontSize(12);
|
||||
@@ -1161,6 +1181,10 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
public WalletTransaction getWalletTransaction() {
|
||||
return walletTx;
|
||||
}
|
||||
|
||||
public boolean isFinal() {
|
||||
return finalProperty.get();
|
||||
}
|
||||
@@ -1173,6 +1197,18 @@ public class TransactionDiagram extends GridPane {
|
||||
this.finalProperty.set(isFinal);
|
||||
}
|
||||
|
||||
public TransactionDiagramLabel getLabel() {
|
||||
return labelProperty.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<TransactionDiagramLabel> labelProperty() {
|
||||
return labelProperty;
|
||||
}
|
||||
|
||||
public void setLabelProperty(TransactionDiagramLabel label) {
|
||||
this.labelProperty.set(label);
|
||||
}
|
||||
|
||||
public OptimizationStrategy getOptimizationStrategy() {
|
||||
return optimizationStrategyProperty.get();
|
||||
}
|
||||
@@ -1300,7 +1336,7 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
private static class LabelContextMenu extends ContextMenu {
|
||||
private class LabelContextMenu extends ContextMenu {
|
||||
public LabelContextMenu(Address address, long value) {
|
||||
if(address != null) {
|
||||
MenuItem copyAddress = new MenuItem("Copy Address");
|
||||
@@ -1316,6 +1352,7 @@ public class TransactionDiagram extends GridPane {
|
||||
showAddress.setOnAction(event -> {
|
||||
hide();
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
|
||||
qrDisplayDialog.initOwner(TransactionDiagram.this.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
});
|
||||
getItems().add(showAddress);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.text.Font;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TransactionDiagramLabel extends HBox {
|
||||
private final List<HBox> outputs = new ArrayList<>();
|
||||
private final Button left;
|
||||
private final Button right;
|
||||
private final IntegerProperty displayedIndex = new SimpleIntegerProperty(-1);
|
||||
|
||||
public TransactionDiagramLabel() {
|
||||
setSpacing(5);
|
||||
setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
left = new Button("");
|
||||
left.setGraphic(getLeftGlyph());
|
||||
left.setOnAction(event -> {
|
||||
int index = displayedIndex.get();
|
||||
if(index > 0) {
|
||||
index--;
|
||||
}
|
||||
displayedIndex.set(index);
|
||||
});
|
||||
|
||||
right = new Button("");
|
||||
right.setGraphic(getRightGlyph());
|
||||
right.setOnAction(event -> {
|
||||
int index = displayedIndex.get();
|
||||
if(index < outputs.size() - 1) {
|
||||
index++;
|
||||
}
|
||||
displayedIndex.set(index);
|
||||
});
|
||||
|
||||
displayedIndex.addListener((observable, oldValue, newValue) -> {
|
||||
left.setDisable(newValue.intValue() <= 0);
|
||||
right.setDisable(newValue.intValue() < 0 || newValue.intValue() >= outputs.size() - 1);
|
||||
if(oldValue.intValue() >= 0 && oldValue.intValue() < outputs.size()) {
|
||||
outputs.get(oldValue.intValue()).setVisible(false);
|
||||
}
|
||||
if(newValue.intValue() >= 0 && newValue.intValue() < outputs.size()) {
|
||||
outputs.get(newValue.intValue()).setVisible(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void update(TransactionDiagram transactionDiagram) {
|
||||
getChildren().clear();
|
||||
outputs.clear();
|
||||
displayedIndex.set(-1);
|
||||
double maxWidth = getMaxWidth();
|
||||
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
List<OutputLabel> outputLabels = new ArrayList<>();
|
||||
|
||||
List<Payment> premixOutputs = walletTx.getPayments().stream().filter(walletTx::isPremixSend).collect(Collectors.toList());
|
||||
if(!premixOutputs.isEmpty()) {
|
||||
OutputLabel premixOutputLabel = getPremixOutputLabel(transactionDiagram, premixOutputs);
|
||||
if(premixOutputLabel != null) {
|
||||
outputLabels.add(premixOutputLabel);
|
||||
}
|
||||
|
||||
Optional<Payment> optWhirlpoolFee = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.WHIRLPOOL_FEE).findFirst();
|
||||
if(optWhirlpoolFee.isPresent()) {
|
||||
OutputLabel whirlpoolFeeOutputLabel = getWhirlpoolFeeOutputLabel(transactionDiagram, optWhirlpoolFee.get(), premixOutputs);
|
||||
outputLabels.add(whirlpoolFeeOutputLabel);
|
||||
}
|
||||
|
||||
List<Payment> badbankOutputs = walletTx.getPayments().stream().filter(walletTx::isBadbankSend).collect(Collectors.toList());
|
||||
List<OutputLabel> badbankOutputLabels = badbankOutputs.stream().map(payment -> getBadbankOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
|
||||
outputLabels.addAll(badbankOutputLabels);
|
||||
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1
|
||||
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_PREMIX && walletTx.getPayments().stream().anyMatch(walletTx::isPostmixSend)) {
|
||||
OutputLabel mixOutputLabel = getMixOutputLabel(transactionDiagram, walletTx.getPayments());
|
||||
if(mixOutputLabel != null) {
|
||||
outputLabels.add(mixOutputLabel);
|
||||
}
|
||||
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1
|
||||
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
|
||||
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
|
||||
if(remixOutputLabel != null) {
|
||||
outputLabels.add(remixOutputLabel);
|
||||
}
|
||||
} else {
|
||||
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
|
||||
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
|
||||
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
|
||||
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
|
||||
}
|
||||
outputLabels.addAll(paymentLabels);
|
||||
|
||||
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
|
||||
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
|
||||
|
||||
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
|
||||
outputLabels.addAll(mixes.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
Map<WalletNode, Long> changeMap = walletTx.getChangeMap();
|
||||
outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList()));
|
||||
|
||||
OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram);
|
||||
if(feeOutputLabel != null) {
|
||||
outputLabels.add(feeOutputLabel);
|
||||
}
|
||||
|
||||
for(OutputLabel outputLabel : outputLabels) {
|
||||
maxWidth = Math.max(maxWidth, outputLabel.width);
|
||||
outputs.add(outputLabel.hBox);
|
||||
getChildren().add(outputLabel.hBox);
|
||||
}
|
||||
|
||||
HBox buttonBox = new HBox();
|
||||
buttonBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
buttonBox.getChildren().addAll(left, right);
|
||||
getChildren().add(buttonBox);
|
||||
|
||||
setMaxWidth(maxWidth);
|
||||
setPrefWidth(maxWidth);
|
||||
|
||||
if(outputLabels.size() > 0) {
|
||||
displayedIndex.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
private OutputLabel getPremixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> premixOutputs) {
|
||||
if(premixOutputs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Payment premixOutput = premixOutputs.get(0);
|
||||
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(premixOutput);
|
||||
String text;
|
||||
if(premixOutputs.size() == 1) {
|
||||
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
|
||||
} else {
|
||||
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
}
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(payment);
|
||||
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getWhirlpoolFeeOutputLabel(TransactionDiagram transactionDiagram, Payment whirlpoolFee, List<Payment> premixOutputs) {
|
||||
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(whirlpoolFee);
|
||||
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getMixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> mixOutputs) {
|
||||
if(mixOutputs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Payment remixOutput = mixOutputs.get(0);
|
||||
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = TransactionDiagram.getPremixGlyph();
|
||||
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getRemixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> remixOutputs) {
|
||||
if(remixOutputs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Payment remixOutput = remixOutputs.get(0);
|
||||
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = TransactionDiagram.getPremixGlyph();
|
||||
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
Wallet toWallet = transactionDiagram.getToWallet(payment);
|
||||
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
|
||||
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(payment);
|
||||
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Map.Entry<WalletNode, Long> changeEntry) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
|
||||
Glyph glyph = TransactionDiagram.getChangeGlyph();
|
||||
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
if(walletTx.getFee() < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Glyph glyph = transactionDiagram.getFeeGlyph();
|
||||
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getOutputLabel(Glyph glyph, String text) {
|
||||
Label icon = new Label();
|
||||
icon.setMinWidth(15);
|
||||
glyph.setFontSize(12);
|
||||
icon.setGraphic(glyph);
|
||||
|
||||
CopyableLabel label = new CopyableLabel();
|
||||
label.setFont(Font.font("Roboto Mono Italic", 13));
|
||||
label.setText(text);
|
||||
|
||||
HBox output = new HBox(5);
|
||||
output.setAlignment(Pos.CENTER);
|
||||
output.managedProperty().bind(output.visibleProperty());
|
||||
output.setVisible(false);
|
||||
output.getChildren().addAll(icon, label);
|
||||
|
||||
double lineWidth = TextUtils.computeTextWidth(label.getFont(), label.getText(), 0.0D) + 2 + getSpacing() + icon.getMinWidth() + 60;
|
||||
return new OutputLabel(output, lineWidth, text);
|
||||
}
|
||||
|
||||
public static Glyph getLeftGlyph() {
|
||||
Glyph caretLeftGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_LEFT);
|
||||
caretLeftGlyph.getStyleClass().add("label-left-icon");
|
||||
caretLeftGlyph.setFontSize(15);
|
||||
return caretLeftGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getRightGlyph() {
|
||||
Glyph caretRightGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_RIGHT);
|
||||
caretRightGlyph.getStyleClass().add("label-right-icon");
|
||||
caretRightGlyph.setFontSize(15);
|
||||
return caretRightGlyph;
|
||||
}
|
||||
|
||||
private record OutputLabel(HBox hBox, double width, String text) {}
|
||||
}
|
||||
@@ -50,8 +50,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
|
||||
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
getSortOrder().add(dateCol);
|
||||
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateAll(WalletTransactionsEntry rootEntry) {
|
||||
@@ -61,16 +60,13 @@ public class TransactionsTreeTable extends CoinTreeTable {
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> dateCol = getColumns().get(0);
|
||||
getSortOrder().add(dateCol);
|
||||
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateHistory() {
|
||||
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
|
||||
sort();
|
||||
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateLabel(Entry entry) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
|
||||
public class TreeTableColumnConfig {
|
||||
private final int index;
|
||||
private final Integer width;
|
||||
private final TreeTableColumn.SortType sortType;
|
||||
|
||||
public TreeTableColumnConfig(int index, Integer width, TreeTableColumn.SortType sortType) {
|
||||
this.index = index;
|
||||
this.width = width;
|
||||
this.sortType = sortType;
|
||||
}
|
||||
|
||||
public TreeTableColumnConfig(int index, String width, String sortType) {
|
||||
this.index = index;
|
||||
this.width = width.isEmpty() ? null : Integer.valueOf(width, 10);
|
||||
this.sortType = sortType.isEmpty() ? null : TreeTableColumn.SortType.valueOf(sortType);
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public Integer getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public TreeTableColumn.SortType getSortType() {
|
||||
return sortType;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return index + "-" + (width == null ? "" : width) + "-" + (sortType == null ? "" : sortType);
|
||||
}
|
||||
|
||||
public static TreeTableColumnConfig fromString(String columnConfig) {
|
||||
String[] parts = columnConfig.split("-", 3);
|
||||
if(parts.length == 3) {
|
||||
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), parts[1], parts[2]);
|
||||
}
|
||||
|
||||
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), (Integer)null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
public class TreeTableConfig {
|
||||
private final List<TreeTableColumnConfig> columnConfigs;
|
||||
|
||||
public TreeTableConfig(CoinTreeTable treeTable) {
|
||||
columnConfigs = new ArrayList<>();
|
||||
TreeTableColumn<Entry, ?> sortColumn = treeTable.getSortOrder().isEmpty() ? null : treeTable.getSortOrder().get(0);
|
||||
for(int i = 0; i < treeTable.getColumns().size(); i++) {
|
||||
TreeTableColumn<Entry, ?> column = treeTable.getColumns().get(i);
|
||||
//TODO: Support column widths
|
||||
columnConfigs.add(new TreeTableColumnConfig(i, null, sortColumn == column ? column.getSortType() : null));
|
||||
}
|
||||
}
|
||||
|
||||
public TreeTableConfig(List<TreeTableColumnConfig> columnConfigs) {
|
||||
this.columnConfigs = columnConfigs;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
StringJoiner joiner = new StringJoiner("|");
|
||||
columnConfigs.stream().forEach(col -> joiner.add(col.toString()));
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
public static TreeTableConfig fromString(String tableConfig) {
|
||||
List<TreeTableColumnConfig> columnConfigs = new ArrayList<>();
|
||||
String[] parts = tableConfig.split("\\|");
|
||||
for(String part : parts) {
|
||||
columnConfigs.add(TreeTableColumnConfig.fromString(part));
|
||||
}
|
||||
|
||||
return new TreeTableConfig(columnConfigs);
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,7 @@ public class UtxosTreeTable extends CoinTreeTable {
|
||||
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
getSortOrder().add(amountCol);
|
||||
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
|
||||
|
||||
getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
}
|
||||
@@ -96,17 +95,14 @@ public class UtxosTreeTable extends CoinTreeTable {
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> amountCol = getColumns().get(getColumns().size() - 1);
|
||||
getSortOrder().add(amountCol);
|
||||
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateHistory() {
|
||||
//Utxo entries should have already been updated, so only a resort required
|
||||
if(!getRoot().getChildren().isEmpty()) {
|
||||
sort();
|
||||
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletExportEvent;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
@@ -17,7 +18,9 @@ import java.util.List;
|
||||
public class WalletExportDialog extends Dialog<Wallet> {
|
||||
private Wallet wallet;
|
||||
|
||||
public WalletExportDialog(Wallet wallet) {
|
||||
public WalletExportDialog(WalletForm walletForm) {
|
||||
this.wallet = walletForm.getWallet();
|
||||
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
@@ -42,9 +45,10 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
|
||||
List<WalletExport> exporters;
|
||||
if(wallet.getPolicyType() == PolicyType.SINGLE) {
|
||||
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels());
|
||||
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
|
||||
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
|
||||
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(), new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels());
|
||||
exporters = List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new ElectrumPersonalServer(), new KeystoneMultisig(),
|
||||
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(), new WalletTransactions(walletForm));
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
|
||||
}
|
||||
@@ -64,6 +68,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(480);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
|
||||
@@ -51,7 +51,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY());
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SeedSigner(), new SpecterDIY(), new Krux());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
@@ -59,7 +59,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
}
|
||||
}
|
||||
|
||||
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()));
|
||||
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig()));
|
||||
if(!selectedWalletForms.isEmpty()) {
|
||||
walletImporters.add(new WalletLabels(selectedWalletForms));
|
||||
}
|
||||
@@ -90,6 +90,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(600);
|
||||
dialogPane.setMinHeight(dialogPane.getPrefHeight());
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
|
||||
@@ -42,9 +42,13 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
|
||||
}
|
||||
|
||||
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate) {
|
||||
this(initialName, hasExistingTransactions, startDate, false);
|
||||
}
|
||||
|
||||
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate, boolean rename) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
|
||||
boolean requestBirthDate = !rename && (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
|
||||
|
||||
setTitle("Wallet Name");
|
||||
dialogPane.setHeaderText("Enter a name for this wallet:");
|
||||
@@ -121,16 +125,16 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
|
||||
));
|
||||
});
|
||||
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType((rename ? "Rename" : "Create") + " Wallet", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
|
||||
name.getText().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
|
||||
name.getText().trim().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
name.setPromptText("Wallet Name");
|
||||
Platform.runLater(name::requestFocus);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText(), existingPicker.getValue()) : null);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText().trim(), existingPicker.getValue()) : null);
|
||||
}
|
||||
|
||||
public static class NameAndBirthDate {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.net.ExchangeSource;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.Function;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.geometry.Side;
|
||||
import javafx.scene.chart.NumberAxis;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WalletSummaryDialog extends Dialog<Void> {
|
||||
public WalletSummaryDialog(List<WalletForm> walletForms) {
|
||||
if(walletForms.isEmpty()) {
|
||||
throw new IllegalArgumentException("No wallets selected to summarize");
|
||||
}
|
||||
|
||||
Wallet masterWallet = walletForms.get(0).getMasterWallet();
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/transactions.css").toExternalForm());
|
||||
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText("Wallet Summary for " + masterWallet.getName());
|
||||
|
||||
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
|
||||
if(!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setSmooth(false);
|
||||
imageView.setImage(image);
|
||||
dialogPane.setGraphic(imageView);
|
||||
}
|
||||
|
||||
HBox hBox = new HBox(40);
|
||||
|
||||
CoinTreeTable table = new CoinTreeTable();
|
||||
|
||||
TreeTableColumn<Entry, String> nameColumn = new TreeTableColumn<>("Wallet");
|
||||
nameColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getLabel());
|
||||
});
|
||||
nameColumn.setCellFactory(p -> new LabelCell());
|
||||
table.getColumns().add(nameColumn);
|
||||
|
||||
TreeTableColumn<Entry, Number> balanceColumn = new TreeTableColumn<>("Balance");
|
||||
balanceColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
balanceColumn.setCellFactory(p -> new CoinCell());
|
||||
table.getColumns().add(balanceColumn);
|
||||
table.setUnitFormat(masterWallet);
|
||||
|
||||
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
|
||||
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
|
||||
TreeTableColumn<Entry, Number> fiatColumn = new TreeTableColumn<>(currencyRate.getCurrency().getSymbol());
|
||||
fiatColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
fiatColumn.setCellFactory(p -> new FiatCell());
|
||||
table.getColumns().add(fiatColumn);
|
||||
table.setCurrencyRate(currencyRate);
|
||||
}
|
||||
|
||||
SummaryEntry rootEntry = new SummaryEntry(walletForms);
|
||||
TreeItem<Entry> rootItem = new TreeItem<>(rootEntry);
|
||||
for(Entry childEntry : rootEntry.getChildren()) {
|
||||
TreeItem<Entry> childItem = new TreeItem<>(childEntry);
|
||||
rootItem.getChildren().add(childItem);
|
||||
childItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)childEntry)));
|
||||
}
|
||||
|
||||
table.setShowRoot(true);
|
||||
table.setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
table.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
table.setPrefWidth(450);
|
||||
|
||||
VBox vBox = new VBox();
|
||||
vBox.getChildren().add(table);
|
||||
|
||||
hBox.getChildren().add(vBox);
|
||||
|
||||
NumberAxis xAxis = new NumberAxis();
|
||||
xAxis.setSide(Side.BOTTOM);
|
||||
xAxis.setForceZeroInRange(false);
|
||||
xAxis.setMinorTickVisible(false);
|
||||
NumberAxis yAxis = new NumberAxis();
|
||||
yAxis.setSide(Side.LEFT);
|
||||
BalanceChart balanceChart = new BalanceChart(xAxis, yAxis);
|
||||
balanceChart.initialize(new WalletTransactionsEntry(masterWallet, true));
|
||||
balanceChart.setAnimated(false);
|
||||
balanceChart.setLegendVisible(false);
|
||||
balanceChart.setVerticalGridLinesVisible(false);
|
||||
|
||||
hBox.getChildren().add(balanceChart);
|
||||
|
||||
getDialogPane().setContent(hBox);
|
||||
|
||||
ButtonType okButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
|
||||
setResizable(true);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
public static class SummaryEntry extends Entry {
|
||||
private SummaryEntry(List<WalletForm> walletForms) {
|
||||
super(walletForms.get(0).getWallet(), walletForms.get(0).getWallet().getName(), walletForms.stream().map(WalletForm::getWalletTransactionsEntry).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getValue() {
|
||||
long value = 0;
|
||||
for(Entry entry : getChildren()) {
|
||||
value += entry.getValue();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntryType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Function getWalletFunction() {
|
||||
return Function.TRANSACTIONS;
|
||||
}
|
||||
}
|
||||
|
||||
public static class UnconfirmedEntry extends Entry {
|
||||
private final WalletTransactionsEntry walletTransactionsEntry;
|
||||
|
||||
private UnconfirmedEntry(WalletTransactionsEntry walletTransactionsEntry) {
|
||||
super(walletTransactionsEntry.getWallet(), "Unconfirmed", Collections.emptyList());
|
||||
this.walletTransactionsEntry = walletTransactionsEntry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getValue() {
|
||||
return walletTransactionsEntry.getMempoolBalance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntryType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Function getWalletFunction() {
|
||||
return Function.TRANSACTIONS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,22 @@ import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.scene.image.Image;
|
||||
import net.sourceforge.zbar.ZBar;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.geom.RoundRectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.WritableRaster;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class WebcamService extends ScheduledService<Image> {
|
||||
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
|
||||
|
||||
private WebcamResolution resolution;
|
||||
private WebcamDevice device;
|
||||
private final WebcamListener listener;
|
||||
@@ -137,6 +143,9 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
if(result == null) {
|
||||
result = readQR(croppedImage);
|
||||
}
|
||||
if(result == null) {
|
||||
result = readQR(invert(croppedImage));
|
||||
}
|
||||
|
||||
if(result != null) {
|
||||
resultProperty.set(result);
|
||||
@@ -147,6 +156,13 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||
|
||||
if(ZBar.isEnabled()) {
|
||||
ZBar.Scan scan = ZBar.scan(bufferedImage);
|
||||
if(scan != null) {
|
||||
return new Result(scan.stringData(), scan.rawData(), new ResultPoint[0], BarcodeFormat.QR_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return qrReader.decode(bitmap, Map.of(DecodeHintType.TRY_HARDER, Boolean.TRUE));
|
||||
} catch(ReaderException e) {
|
||||
@@ -175,6 +191,29 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
return new CroppedDimension(x, y, squareSize);
|
||||
}
|
||||
|
||||
public BufferedImage invert(BufferedImage inImg) {
|
||||
try {
|
||||
int width = inImg.getWidth();
|
||||
int height = inImg.getHeight();
|
||||
BufferedImage outImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
WritableRaster outRaster = outImg.getRaster();
|
||||
WritableRaster inRaster = inImg.getRaster();
|
||||
|
||||
for(int y = 0; y < height; y++) {
|
||||
for(int x = 0; x < width; x++) {
|
||||
for(int i = 0; i < outRaster.getNumBands(); i++) {
|
||||
outRaster.setSample(x, y, i, 255 - inRaster.getSample(x, y, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outImg;
|
||||
} catch(Exception e) {
|
||||
log.warn("Error inverting image", e);
|
||||
return inImg;
|
||||
}
|
||||
}
|
||||
|
||||
public Result getResult() {
|
||||
return resultProperty.get();
|
||||
}
|
||||
|
||||
@@ -5,20 +5,15 @@ import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class FeeRatesUpdatedEvent {
|
||||
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
|
||||
private final Map<Integer, Double> targetBlockFeeRates;
|
||||
private final Set<MempoolRateSize> mempoolRateSizes;
|
||||
|
||||
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
|
||||
super(mempoolRateSizes);
|
||||
this.targetBlockFeeRates = targetBlockFeeRates;
|
||||
this.mempoolRateSizes = mempoolRateSizes;
|
||||
}
|
||||
|
||||
public Map<Integer, Double> getTargetBlockFeeRates() {
|
||||
return targetBlockFeeRates;
|
||||
}
|
||||
|
||||
public Set<MempoolRateSize> getMempoolRateSizes() {
|
||||
return mempoolRateSizes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
/**
|
||||
* The event is posted when the first set of mempool entries (txid and vsizes) have been retrieved from the node.
|
||||
* Cormorant only.
|
||||
*/
|
||||
public class MempoolEntriesInitializedEvent {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public class MempoolRateSizesUpdatedEvent {
|
||||
private final Set<MempoolRateSize> mempoolRateSizes;
|
||||
|
||||
public MempoolRateSizesUpdatedEvent(Set<MempoolRateSize> mempoolRateSizes) {
|
||||
this.mempoolRateSizes = mempoolRateSizes;
|
||||
}
|
||||
|
||||
public Set<MempoolRateSize> getMempoolRateSizes() {
|
||||
return mempoolRateSizes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
|
||||
public class PSBTReorderedEvent {
|
||||
private final PSBT psbt;
|
||||
|
||||
public PSBTReorderedEvent(PSBT psbt) {
|
||||
this.psbt = psbt;
|
||||
}
|
||||
|
||||
public PSBT getPsbt() {
|
||||
return psbt;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
|
||||
import com.sparrowwallet.drongo.bip47.PaymentCode;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
@@ -14,21 +15,23 @@ public class SpendUtxoEvent {
|
||||
private final List<Payment> payments;
|
||||
private final List<byte[]> opReturns;
|
||||
private final Long fee;
|
||||
private final boolean includeSpentMempoolOutputs;
|
||||
private final boolean requireAllUtxos;
|
||||
private final BlockTransaction replacedTransaction;
|
||||
private final Pool pool;
|
||||
private final PaymentCode paymentCode;
|
||||
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
|
||||
this(wallet, utxos, null, null, null, false);
|
||||
this(wallet, utxos, null, null, null, false, null);
|
||||
}
|
||||
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean includeSpentMempoolOutputs) {
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) {
|
||||
this.wallet = wallet;
|
||||
this.utxos = utxos;
|
||||
this.payments = payments;
|
||||
this.opReturns = opReturns;
|
||||
this.fee = fee;
|
||||
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
|
||||
this.requireAllUtxos = requireAllUtxos;
|
||||
this.replacedTransaction = replacedTransaction;
|
||||
this.pool = null;
|
||||
this.paymentCode = null;
|
||||
}
|
||||
@@ -39,7 +42,8 @@ public class SpendUtxoEvent {
|
||||
this.payments = payments;
|
||||
this.opReturns = opReturns;
|
||||
this.fee = fee;
|
||||
this.includeSpentMempoolOutputs = false;
|
||||
this.requireAllUtxos = false;
|
||||
this.replacedTransaction = null;
|
||||
this.pool = pool;
|
||||
this.paymentCode = null;
|
||||
}
|
||||
@@ -50,7 +54,8 @@ public class SpendUtxoEvent {
|
||||
this.payments = payments;
|
||||
this.opReturns = opReturns;
|
||||
this.fee = null;
|
||||
this.includeSpentMempoolOutputs = false;
|
||||
this.requireAllUtxos = false;
|
||||
this.replacedTransaction = null;
|
||||
this.pool = null;
|
||||
this.paymentCode = paymentCode;
|
||||
}
|
||||
@@ -75,8 +80,12 @@ public class SpendUtxoEvent {
|
||||
return fee;
|
||||
}
|
||||
|
||||
public boolean isIncludeSpentMempoolOutputs() {
|
||||
return includeSpentMempoolOutputs;
|
||||
public boolean isRequireAllUtxos() {
|
||||
return requireAllUtxos;
|
||||
}
|
||||
|
||||
public BlockTransaction getReplacedTransaction() {
|
||||
return replacedTransaction;
|
||||
}
|
||||
|
||||
public Pool getPool() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
public class WhirlpoolIndexHighFrequencyEvent {
|
||||
private final Wallet wallet;
|
||||
|
||||
public WhirlpoolIndexHighFrequencyEvent(Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public class FontAwesome5 extends GlyphFont {
|
||||
BTC('\uf15a'),
|
||||
BULLSEYE('\uf140'),
|
||||
CAMERA('\uf030'),
|
||||
CARET_LEFT('\uf0d9'),
|
||||
CARET_RIGHT('\uf0da'),
|
||||
CHECK_CIRCLE('\uf058'),
|
||||
CIRCLE('\uf111'),
|
||||
COINS('\uf51e'),
|
||||
|
||||
@@ -28,7 +28,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Caravan Multisig";
|
||||
return "Unchained or Caravan Multisig";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
|
||||
import com.sparrowwallet.sparrow.io.satochip.SatoCardApi;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import org.controlsfx.tools.Platform;
|
||||
@@ -17,11 +18,13 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import javax.smartcardio.CardTerminal;
|
||||
import javax.smartcardio.CardTerminals;
|
||||
import javax.smartcardio.TerminalFactory;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@@ -32,19 +35,30 @@ public abstract class CardApi {
|
||||
new File("/usr/lib/libpcsclite.so.1"),
|
||||
new File("/usr/local/lib/libpcsclite.so.1"),
|
||||
new File("/lib/x86_64-linux-gnu/libpcsclite.so.1"),
|
||||
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1")};
|
||||
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1"),
|
||||
new File("/usr/lib64/libpcsclite.so.1"),
|
||||
new File("/usr/lib/x86_64-linux-gnu/libpcsclite.so.1")};
|
||||
|
||||
private static boolean initialized;
|
||||
|
||||
public static List<WalletModel> getConnectedCards() throws CardException {
|
||||
List<WalletModel> cards = new ArrayList<>();
|
||||
|
||||
try {
|
||||
CkCardApi ckCardApi = new CkCardApi(null, null);
|
||||
return List.of(ckCardApi.getCardType());
|
||||
cards.add(ckCardApi.getCardType());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
try {
|
||||
SatoCardApi satoCardApi = new SatoCardApi(null, null);
|
||||
cards.add(satoCardApi.getCardType());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
public static CardApi getCardApi(WalletModel walletModel, String pin) throws CardException {
|
||||
@@ -52,6 +66,10 @@ public abstract class CardApi {
|
||||
return new CkCardApi(walletModel, pin);
|
||||
}
|
||||
|
||||
if(walletModel == WalletModel.SATOCHIP) {
|
||||
return new SatoCardApi(walletModel, pin);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
|
||||
}
|
||||
|
||||
@@ -90,11 +108,15 @@ public abstract class CardApi {
|
||||
public abstract void disconnect();
|
||||
|
||||
public static boolean isReaderAvailable() {
|
||||
return !getAvailableTerminals().isEmpty();
|
||||
}
|
||||
|
||||
public static List<CardTerminal> getAvailableTerminals() {
|
||||
setLibrary();
|
||||
|
||||
try {
|
||||
TerminalFactory tf = TerminalFactory.getDefault();
|
||||
return !tf.terminals().list().isEmpty();
|
||||
return tf.terminals().list();
|
||||
} catch(Exception e) {
|
||||
Throwable cause = Throwables.getRootCause(e);
|
||||
if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) {
|
||||
@@ -106,7 +128,7 @@ public abstract class CardApi {
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private static void recoverNoService() {
|
||||
|
||||
@@ -6,5 +6,5 @@ import javax.smartcardio.CardException;
|
||||
|
||||
public interface CardImport extends ImportExport {
|
||||
boolean isInitialized() throws CardException;
|
||||
void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException;
|
||||
void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public class Config {
|
||||
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
|
||||
private QRDensity qrDensity;
|
||||
private Boolean hdCapture;
|
||||
private boolean useZbar = true;
|
||||
private String webcamDevice;
|
||||
private ServerType serverType;
|
||||
private Server publicElectrumServer;
|
||||
@@ -77,6 +78,7 @@ public class Config {
|
||||
private int maxPageSize = DEFAULT_PAGE_SIZE;
|
||||
private boolean usePayNym;
|
||||
private boolean sameAppMixing;
|
||||
private boolean mempoolFullRbf;
|
||||
private Double appWidth;
|
||||
private Double appHeight;
|
||||
|
||||
@@ -404,6 +406,10 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isUseZbar() {
|
||||
return useZbar;
|
||||
}
|
||||
|
||||
public String getWebcamDevice() {
|
||||
return webcamDevice;
|
||||
}
|
||||
@@ -665,6 +671,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isMempoolFullRbf() {
|
||||
return mempoolFullRbf;
|
||||
}
|
||||
|
||||
public void setMempoolFullRbf(boolean mempoolFullRbf) {
|
||||
this.mempoolFullRbf = mempoolFullRbf;
|
||||
flush();
|
||||
}
|
||||
|
||||
public Double getAppWidth() {
|
||||
return appWidth;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class Descriptor implements WalletImport, WalletExport {
|
||||
|
||||
@Override
|
||||
public boolean isWalletExportScannable() {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -36,7 +36,7 @@ public class Hwi {
|
||||
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
|
||||
private static final String HWI_HOME_DIR = "hwi";
|
||||
private static final String HWI_VERSION_PREFIX = "hwi-";
|
||||
private static final String HWI_VERSION = "2.2.1";
|
||||
private static final String HWI_VERSION = "2.3.1";
|
||||
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
|
||||
|
||||
private static boolean isPromptActive = false;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class JadeMultisig extends ColdcardMultisig {
|
||||
@Override
|
||||
public String getName() {
|
||||
@@ -38,4 +41,30 @@ public class JadeMultisig extends ColdcardMultisig {
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return "Import the QR created using Options > Wallet > Registered Wallets on your Jade.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
Wallet wallet = super.importWallet(inputStream, password);
|
||||
for(Keystore keystore : wallet.getKeystores()) {
|
||||
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Jade"));
|
||||
keystore.setWalletModel(WalletModel.JADE);
|
||||
}
|
||||
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
public class Krux extends SpecterDIY {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Krux";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import file or QR created on your Krux by selecting Extended Public Key from the main menu once you have loaded your mnemonic.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.KRUX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.client.j2se.MatrixToImageConfig;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
@@ -9,10 +10,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,8 +30,8 @@ import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.*;
|
||||
import java.util.Locale;
|
||||
import java.util.Scanner;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
|
||||
public class PdfUtils {
|
||||
private static final Logger log = LoggerFactory.getLogger(PdfUtils.class);
|
||||
@@ -100,7 +105,7 @@ public class PdfUtils {
|
||||
|
||||
private static javafx.scene.image.Image getQrCode(String fragment) throws IOException, WriterException {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT, Map.of(EncodeHintType.MARGIN, "2"));
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
|
||||
@@ -108,4 +113,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
|
||||
|
||||
@@ -39,8 +39,10 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
Storage tempStorage = new Storage(persistence, tempFile);
|
||||
tempStorage.setKeyDeriver(storage.getKeyDeriver());
|
||||
tempStorage.setEncryptionPubKey(storage.getEncryptionPubKey());
|
||||
tempStorage.saveWallet(exportedWallet);
|
||||
for(Wallet childWallet : exportedWallet.getChildWallets()) {
|
||||
|
||||
Wallet copy = exportedWallet.copy();
|
||||
tempStorage.saveWallet(copy);
|
||||
for(Wallet childWallet : copy.getChildWallets()) {
|
||||
tempStorage.saveWallet(childWallet);
|
||||
}
|
||||
persistence.close();
|
||||
|
||||
@@ -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,7 +8,6 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
||||
import com.sparrowwallet.sparrow.soroban.Soroban;
|
||||
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
@@ -265,8 +264,11 @@ public class Storage {
|
||||
persistence.copyWallet(walletFile, outputStream);
|
||||
}
|
||||
|
||||
public boolean delete() {
|
||||
deleteBackups();
|
||||
public boolean delete(boolean deleteBackups) {
|
||||
if(deleteBackups) {
|
||||
deleteBackups();
|
||||
}
|
||||
|
||||
return IOUtils.secureDelete(walletFile);
|
||||
}
|
||||
|
||||
@@ -735,18 +737,44 @@ public class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
public static class CopyWalletService extends Service<Void> {
|
||||
private final Wallet wallet;
|
||||
private final File newWalletFile;
|
||||
|
||||
public CopyWalletService(Wallet wallet, File newWalletFile) {
|
||||
this.wallet = wallet;
|
||||
this.newWalletFile = newWalletFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
protected Void call() throws IOException, ExportException {
|
||||
Sparrow export = new Sparrow();
|
||||
try(BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(newWalletFile))) {
|
||||
export.exportWallet(wallet, outputStream);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeleteWalletService extends ScheduledService<Boolean> {
|
||||
private final Storage storage;
|
||||
private final boolean deleteBackups;
|
||||
|
||||
public DeleteWalletService(Storage storage) {
|
||||
public DeleteWalletService(Storage storage, boolean deleteBackups) {
|
||||
this.storage = storage;
|
||||
this.deleteBackups = deleteBackups;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() {
|
||||
return storage.delete();
|
||||
return storage.delete(deleteBackups);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ public interface WalletImport extends FileImport {
|
||||
String getWalletImportDescription();
|
||||
Wallet importWallet(InputStream inputStream, String password) throws ImportException;
|
||||
boolean isWalletImportScannable();
|
||||
default boolean isWalletImportFileFormatAvailable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -36,7 +37,7 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Wallet Labels";
|
||||
return "Labels";
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,29 +55,32 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
|
||||
for(Keystore keystore : exportWallet.getKeystores()) {
|
||||
if(keystore.getLabel() != null && !keystore.getLabel().isEmpty()) {
|
||||
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null));
|
||||
labels.add(new Label(Type.xpub, keystore.getExtendedPublicKey().toString(), keystore.getLabel(), null, null));
|
||||
}
|
||||
}
|
||||
|
||||
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
|
||||
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
|
||||
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin));
|
||||
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
|
||||
}
|
||||
}
|
||||
|
||||
for(WalletNode addressNode : exportWallet.getWalletAddresses().values()) {
|
||||
if(addressNode.getLabel() != null && !addressNode.getLabel().isEmpty()) {
|
||||
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null));
|
||||
labels.add(new Label(Type.addr, addressNode.getAddress().toString(), addressNode.getLabel(), null, null));
|
||||
}
|
||||
}
|
||||
|
||||
for(BlockTransactionHashIndex txo : exportWallet.getWalletTxos().keySet()) {
|
||||
String spendable = (txo.isSpent() ? null : txo.getStatus() == Status.FROZEN ? "false" : "true");
|
||||
if(txo.getLabel() != null && !txo.getLabel().isEmpty()) {
|
||||
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null));
|
||||
labels.add(new Label(Type.output, txo.toString(), txo.getLabel(), null, spendable));
|
||||
} else if(!txo.isSpent()) {
|
||||
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
|
||||
}
|
||||
|
||||
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
|
||||
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null));
|
||||
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +144,15 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(label == null || label.type == null || label.ref == null || label.label == null || label.label.isEmpty()) {
|
||||
if(label == null || label.type == null || label.ref == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(label.type == Type.output) {
|
||||
if((label.label == null || label.label.isEmpty()) && label.spendable == null) {
|
||||
continue;
|
||||
}
|
||||
} else if(label.label == null || label.label.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -152,6 +164,7 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
|
||||
Map<Wallet, List<Keystore>> changedWalletKeystores = new LinkedHashMap<>();
|
||||
Map<Wallet, List<Entry>> changedWalletEntries = new LinkedHashMap<>();
|
||||
Map<Wallet, List<BlockTransactionHashIndex>> changedWalletUtxoStatuses = new LinkedHashMap<>();
|
||||
|
||||
for(WalletForm walletForm : walletForms) {
|
||||
Wallet wallet = walletForm.getWallet();
|
||||
@@ -216,9 +229,21 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
BlockTransactionHashIndex reference = txioEntry.getHashIndex();
|
||||
if((label.type == Type.output && txioEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|
||||
|| (label.type == Type.input && txioEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
|
||||
txioEntry.getHashIndex().setLabel(label.label);
|
||||
txioEntry.labelProperty().set(label.label);
|
||||
addChangedEntry(changedWalletEntries, txioEntry);
|
||||
if(label.label != null && !label.label.isEmpty()) {
|
||||
reference.setLabel(label.label);
|
||||
txioEntry.labelProperty().set(label.label);
|
||||
addChangedEntry(changedWalletEntries, txioEntry);
|
||||
}
|
||||
|
||||
if(label.type == Type.output && !reference.isSpent()) {
|
||||
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
|
||||
reference.setStatus(Status.FROZEN);
|
||||
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
|
||||
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
|
||||
reference.setStatus(null);
|
||||
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +273,10 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletEntries.getKey(), walletEntries.getValue(), false));
|
||||
}
|
||||
|
||||
for(Map.Entry<Wallet, List<BlockTransactionHashIndex>> walletUtxos : changedWalletUtxoStatuses.entrySet()) {
|
||||
EventManager.get().post(new WalletUtxoStatusChangedEvent(walletUtxos.getKey(), walletUtxos.getValue()));
|
||||
}
|
||||
|
||||
return walletForms.get(0).getWallet();
|
||||
}
|
||||
|
||||
@@ -256,7 +285,9 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
BlockTransactionHashIndex reference = hashIndexEntry.getHashIndex();
|
||||
if((label.type == Type.output && hashIndexEntry.getType() == HashIndexEntry.Type.OUTPUT && reference.toString().equals(label.ref))
|
||||
|| (label.type == Type.input && hashIndexEntry.getType() == HashIndexEntry.Type.INPUT && reference.toString().equals(label.ref))) {
|
||||
hashIndexEntry.labelProperty().set(label.label);
|
||||
if(label.label != null && !label.label.isEmpty()) {
|
||||
hashIndexEntry.labelProperty().set(label.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +297,11 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
entries.add(entry);
|
||||
}
|
||||
|
||||
private static void addChangedUtxo(Map<Wallet, List<BlockTransactionHashIndex>> changedUtxos, TransactionHashIndexEntry utxoEntry) {
|
||||
List<BlockTransactionHashIndex> utxos = changedUtxos.computeIfAbsent(utxoEntry.getWallet(), w -> new ArrayList<>());
|
||||
utxos.add(utxoEntry.getHashIndex());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportScannable() {
|
||||
return false;
|
||||
@@ -281,16 +317,18 @@ public class WalletLabels implements WalletImport, WalletExport {
|
||||
}
|
||||
|
||||
private static class Label {
|
||||
public Label(Type type, String ref, String label, String origin) {
|
||||
public Label(Type type, String ref, String label, String origin, String spendable) {
|
||||
this.type = type;
|
||||
this.ref = ref;
|
||||
this.label = label;
|
||||
this.origin = origin;
|
||||
this.spendable = spendable;
|
||||
}
|
||||
|
||||
Type type;
|
||||
String ref;
|
||||
String label;
|
||||
String origin;
|
||||
String spendable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.csvreader.CsvWriter;
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionInput;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.control.EntryCell;
|
||||
import com.sparrowwallet.sparrow.net.ExchangeSource;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
|
||||
import org.apache.commons.lang3.time.DateUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WalletTransactions implements WalletExport {
|
||||
private static final long ONE_DAY = 24*60*60*1000L;
|
||||
|
||||
private final WalletForm walletForm;
|
||||
|
||||
public WalletTransactions(WalletForm walletForm) {
|
||||
this.walletForm = walletForm;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Transactions";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.TRANSACTIONS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
|
||||
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
|
||||
|
||||
ExchangeSource exchangeSource = Config.get().getExchangeSource();
|
||||
if(Config.get().getExchangeSource() == null) {
|
||||
exchangeSource = ExchangeSource.COINGECKO;
|
||||
}
|
||||
|
||||
Currency fiatCurrency = (exchangeSource == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency());
|
||||
Map<Date, Double> fiatRates = new HashMap<>();
|
||||
if(fiatCurrency != null && !walletTransactionsEntry.getChildren().isEmpty()) {
|
||||
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
|
||||
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.summarizingLong(Date::getTime));
|
||||
fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(stats.getMin() - ONE_DAY), new Date(stats.getMax()));
|
||||
}
|
||||
|
||||
BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit();
|
||||
if(bitcoinUnit == null || bitcoinUnit.equals(BitcoinUnit.AUTO)) {
|
||||
bitcoinUnit = walletForm.getWallet().getAutoUnit();
|
||||
}
|
||||
|
||||
try {
|
||||
CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8);
|
||||
|
||||
writer.write("Date");
|
||||
writer.write("Label");
|
||||
writer.write("Value");
|
||||
writer.write("Balance");
|
||||
writer.write("Fee");
|
||||
if(fiatCurrency != null) {
|
||||
writer.write("Value (" + fiatCurrency.getCurrencyCode() + ")");
|
||||
}
|
||||
writer.write("Txid");
|
||||
writer.endRecord();
|
||||
|
||||
for(Entry entry : walletTransactionsEntry.getChildren()) {
|
||||
TransactionEntry txEntry = (TransactionEntry)entry;
|
||||
writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(txEntry.getBlockTransaction().getDate()));
|
||||
writer.write(txEntry.getLabel());
|
||||
writer.write(getCoinValue(bitcoinUnit, txEntry.getValue()));
|
||||
writer.write(getCoinValue(bitcoinUnit, txEntry.getBalance()));
|
||||
Long fee = txEntry.getValue() < 0 ? getFee(wallet, txEntry.getBlockTransaction()) : null;
|
||||
writer.write(fee == null ? "" : getCoinValue(bitcoinUnit, fee));
|
||||
if(fiatCurrency != null) {
|
||||
Double fiatValue = getFiatValue(txEntry, fiatRates);
|
||||
writer.write(fiatValue == null ? "" : getFiatValue(fiatValue));
|
||||
}
|
||||
writer.write(txEntry.getBlockTransaction().getHash().toString());
|
||||
writer.endRecord();
|
||||
}
|
||||
|
||||
if(fiatCurrency != null) {
|
||||
writer.endRecord();
|
||||
writer.writeComment(" Historical " + fiatCurrency.getCurrencyCode() + " values are taken from daily rates and should only be considered as approximate.");
|
||||
}
|
||||
|
||||
writer.close();
|
||||
} catch(IOException e) {
|
||||
throw new ExportException("Error writing transactions CSV", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Long getFee(Wallet wallet, BlockTransaction blockTransaction) {
|
||||
long fee = 0L;
|
||||
for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) {
|
||||
if(txInput.isCoinBase()) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
BlockTransaction inputTx = wallet.getWalletTransaction(txInput.getOutpoint().getHash());
|
||||
if(inputTx == null || inputTx.getTransaction().getOutputs().size() <= txInput.getOutpoint().getIndex()) {
|
||||
return null;
|
||||
}
|
||||
TransactionOutput spentOutput = inputTx.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
|
||||
fee += spentOutput.getValue();
|
||||
}
|
||||
|
||||
for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) {
|
||||
fee -= txOutput.getValue();
|
||||
}
|
||||
|
||||
return fee;
|
||||
}
|
||||
|
||||
private String getCoinValue(BitcoinUnit bitcoinUnit, Long value) {
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
return BitcoinUnit.BTC.equals(bitcoinUnit) ? format.tableFormatBtcValue(value) : String.format(Locale.ENGLISH, "%d", value);
|
||||
}
|
||||
|
||||
private String getFiatValue(Double value) {
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
return format.tableFormatCurrencyValue(value);
|
||||
}
|
||||
|
||||
private Double getFiatValue(TransactionEntry txEntry, Map<Date, Double> fiatRates) {
|
||||
Double dayRate = null;
|
||||
if(txEntry.getBlockTransaction().getDate() == null) {
|
||||
if(AppServices.getFiatCurrencyExchangeRate() != null) {
|
||||
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
|
||||
}
|
||||
} else {
|
||||
dayRate = fiatRates.get(DateUtils.truncate(txEntry.getBlockTransaction().getDate(), Calendar.DAY_OF_MONTH));
|
||||
}
|
||||
|
||||
if(dayRate != null) {
|
||||
return dayRate * txEntry.getValue() / Transaction.SATOSHIS_PER_BITCOIN;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Exports a CSV file containing dates and values of the transactions in this wallet. Additionally, if a fiat currency has been configured, the fiat value on the day they were first included in the blockchain.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExportFileExtension(Wallet wallet) {
|
||||
return "csv";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletExportScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ import org.slf4j.LoggerFactory;
|
||||
import javax.smartcardio.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.Map;
|
||||
|
||||
public class CardTransport {
|
||||
@@ -41,15 +40,32 @@ public class CardTransport {
|
||||
throw new IllegalStateException("No reader connected");
|
||||
}
|
||||
|
||||
CardTerminal cardTerminal = (CardTerminal)terminals.get(0);
|
||||
connection = cardTerminal.connect("*");
|
||||
Card connection = null;
|
||||
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
|
||||
try {
|
||||
connection = getConnection(iter.next());
|
||||
break;
|
||||
} catch(CardException e) {
|
||||
if(!iter.hasNext()) {
|
||||
log.debug(e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
private Card getConnection(CardTerminal cardTerminal) throws CardException {
|
||||
Card connection = cardTerminal.connect("*");
|
||||
|
||||
CardChannel cardChannel = connection.getBasicChannel();
|
||||
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
|
||||
if(resp.getSW() != SW_OKAY) {
|
||||
log.error("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported.");
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
JsonObject send(String cmd, Map<String, Object> args) throws CardException {
|
||||
|
||||
@@ -26,7 +26,7 @@ public class Tapsigner implements KeystoreCardImport {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException {
|
||||
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
|
||||
if(pin.length() < 6) {
|
||||
throw new CardException("PIN too short.");
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class Tapsigner implements KeystoreCardImport {
|
||||
throw new IllegalStateException("Card is already initialized.");
|
||||
}
|
||||
cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
|
||||
cardApi.initialize(0, chainCode);
|
||||
cardApi.initialize(0, entropy);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
|
||||
@@ -285,7 +285,7 @@ public class DbPersistence implements Persistence {
|
||||
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
|
||||
for(Entry entry : dirtyPersistables.labelEntries) {
|
||||
if(entry instanceof TransactionEntry && ((TransactionEntry)entry).getBlockTransaction().getId() != null) {
|
||||
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), entry.getLabel());
|
||||
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), blockTransactionDao.truncate(entry.getLabel()));
|
||||
} else if(entry instanceof NodeEntry) {
|
||||
WalletNode addressNode = ((NodeEntry)entry).getNode();
|
||||
if(addressNode.getId() == null) {
|
||||
@@ -301,9 +301,9 @@ public class DbPersistence implements Persistence {
|
||||
walletNodeDao.updateNodeAddressData(addressNode.getId(), addressNode.getAddressData());
|
||||
}
|
||||
|
||||
walletNodeDao.updateNodeLabel(addressNode.getId(), entry.getLabel());
|
||||
walletNodeDao.updateNodeLabel(addressNode.getId(), walletNodeDao.truncate(entry.getLabel()));
|
||||
} else if(entry instanceof HashIndexEntry && ((HashIndexEntry)entry).getHashIndex().getId() != null) {
|
||||
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), entry.getLabel());
|
||||
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), walletNodeDao.truncate(entry.getLabel()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,7 +583,7 @@ public class DbPersistence implements Persistence {
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return dataSource.isClosed();
|
||||
return dataSource == null || dataSource.isClosed();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface MixConfigDao {
|
||||
|
||||
default void addMixConfig(Wallet wallet) {
|
||||
if(wallet.getMixConfig() != null) {
|
||||
wallet.getMixConfig().setId(null);
|
||||
addOrUpdate(wallet, wallet.getMixConfig());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
/**
|
||||
* ISO7816-4 APDU.
|
||||
*/
|
||||
public class APDUCommand {
|
||||
protected int cla;
|
||||
protected int ins;
|
||||
protected int p1;
|
||||
protected int p2;
|
||||
protected byte[] data;
|
||||
protected boolean needsLE;
|
||||
|
||||
/**
|
||||
* Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array.
|
||||
*
|
||||
* @param cla class byte
|
||||
* @param ins instruction code
|
||||
* @param p1 P1 parameter
|
||||
* @param p2 P2 parameter
|
||||
* @param data the APDU data
|
||||
*/
|
||||
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
|
||||
this(cla, ins, p1, p2, data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array.
|
||||
* The LE byte, if sent, is set to 0.
|
||||
*
|
||||
* @param cla class byte
|
||||
* @param ins instruction code
|
||||
* @param p1 P1 parameter
|
||||
* @param p2 P2 parameter
|
||||
* @param data the APDU data
|
||||
* @param needsLE whether the LE byte should be sent or not
|
||||
*/
|
||||
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) {
|
||||
this.cla = cla & 0xff;
|
||||
this.ins = ins & 0xff;
|
||||
this.p1 = p1 & 0xff;
|
||||
this.p2 = p2 & 0xff;
|
||||
this.data = data;
|
||||
this.needsLE = needsLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU in order to send it to the card.
|
||||
*
|
||||
* @return the byte array representation of the APDU
|
||||
*/
|
||||
public byte[] serialize() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(this.cla);
|
||||
out.write(this.ins);
|
||||
out.write(this.p1);
|
||||
out.write(this.p2);
|
||||
out.write(this.data.length);
|
||||
out.write(this.data, 0, this.data.length);
|
||||
|
||||
if(this.needsLE) {
|
||||
out.write(0); // Response length
|
||||
}
|
||||
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU to human readable hex string format
|
||||
*
|
||||
* @return the hex string representation of the APDU
|
||||
*/
|
||||
public String toHexString() {
|
||||
byte[] raw = this.serialize();
|
||||
if(raw == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Utils.bytesToHex(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CLA of the APDU
|
||||
*
|
||||
* @return the CLA of the APDU
|
||||
*/
|
||||
public int getCla() {
|
||||
return cla;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the INS of the APDU
|
||||
*
|
||||
* @return the INS of the APDU
|
||||
*/
|
||||
public int getIns() {
|
||||
return ins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the P1 of the APDU
|
||||
*
|
||||
* @return the P1 of the APDU
|
||||
*/
|
||||
public int getP1() {
|
||||
return p1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the P2 of the APDU
|
||||
*
|
||||
* @return the P2 of the APDU
|
||||
*/
|
||||
public int getP2() {
|
||||
return p2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data field of the APDU
|
||||
*
|
||||
* @return the data field of the APDU
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether LE is sent or not.
|
||||
*
|
||||
* @return whether LE is sent or not
|
||||
*/
|
||||
public boolean getNeedsLE() {
|
||||
return this.needsLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
/**
|
||||
* ISO7816-4 APDU response.
|
||||
*/
|
||||
public class APDUResponse {
|
||||
public static final int SW_OK = 0x9000;
|
||||
public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982;
|
||||
public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983;
|
||||
public static final int SW_CARD_LOCKED = 0x6283;
|
||||
public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88;
|
||||
public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed
|
||||
public static final int SW_WRONG_PIN_MASK = 0x63C0;
|
||||
public static final String HEXES = "0123456789ABCDEF";
|
||||
|
||||
private final byte[] apdu;
|
||||
private byte[] data;
|
||||
private int sw;
|
||||
private int sw1;
|
||||
private int sw2;
|
||||
|
||||
/**
|
||||
* Creates an APDU object by parsing the raw response from the card.
|
||||
*
|
||||
* @param apdu the raw response from the card.
|
||||
*/
|
||||
public APDUResponse(byte[] apdu) {
|
||||
if(apdu.length < 2) {
|
||||
throw new IllegalArgumentException("APDU response must be at least 2 bytes");
|
||||
}
|
||||
this.apdu = apdu;
|
||||
this.parse();
|
||||
}
|
||||
|
||||
public APDUResponse(byte[] data, byte sw1, byte sw2) {
|
||||
byte[] apdu = new byte[data.length + 2];
|
||||
System.arraycopy(data, 0, apdu, 0, data.length);
|
||||
apdu[data.length] = sw1;
|
||||
apdu[data.length + 1] = sw2;
|
||||
this.apdu = apdu;
|
||||
this.parse();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses the APDU response, separating the response data from SW.
|
||||
*/
|
||||
private void parse() {
|
||||
int length = this.apdu.length;
|
||||
|
||||
this.sw1 = this.apdu[length - 2] & 0xff;
|
||||
this.sw2 = this.apdu[length - 1] & 0xff;
|
||||
this.sw = (this.sw1 << 8) | this.sw2;
|
||||
|
||||
this.data = new byte[length - 2];
|
||||
System.arraycopy(this.apdu, 0, this.data, 0, length - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data field of this APDU.
|
||||
*
|
||||
* @return the data field of this APDU
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Status Word.
|
||||
*
|
||||
* @return the status word
|
||||
*/
|
||||
public int getSw() {
|
||||
return this.sw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SW1 byte
|
||||
*
|
||||
* @return SW1
|
||||
*/
|
||||
public int getSw1() {
|
||||
return this.sw1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SW2 byte
|
||||
*
|
||||
* @return SW2
|
||||
*/
|
||||
public int getSw2() {
|
||||
return this.sw2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw unparsed response.
|
||||
*
|
||||
* @return raw APDU data
|
||||
*/
|
||||
public byte[] getBytes() {
|
||||
return this.apdu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU to human readable hex string format
|
||||
*
|
||||
* @return the hex string representation of the APDU
|
||||
*/
|
||||
public String toHexString() {
|
||||
byte[] raw = this.apdu;
|
||||
if(raw == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Utils.bytesToHex(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
public final class Constants {
|
||||
|
||||
// Prevents instantiation of class
|
||||
private Constants() {
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* Instruction codes *
|
||||
****************************************/
|
||||
public static final byte CLA = (byte) 0xB0;
|
||||
// Applet initialization
|
||||
public static final byte INS_SETUP = (byte) 0x2A;
|
||||
// Keys' use and management
|
||||
public static final byte INS_IMPORT_KEY = (byte) 0x32;
|
||||
public static final byte INS_RESET_KEY = (byte) 0x33;
|
||||
public static final byte INS_GET_PUBLIC_FROM_PRIVATE = (byte) 0x35;
|
||||
// External authentication
|
||||
public static final byte INS_CREATE_PIN = (byte) 0x40; //TODO: remove?
|
||||
public static final byte INS_VERIFY_PIN = (byte) 0x42;
|
||||
public static final byte INS_CHANGE_PIN = (byte) 0x44;
|
||||
public static final byte INS_UNBLOCK_PIN = (byte) 0x46;
|
||||
public static final byte INS_LOGOUT_ALL = (byte) 0x60;
|
||||
// Status information
|
||||
public static final byte INS_LIST_PINS = (byte) 0x48;
|
||||
public static final byte INS_GET_STATUS = (byte) 0x3C;
|
||||
public static final byte INS_CARD_LABEL = (byte) 0x3D;
|
||||
// HD wallet
|
||||
public static final byte INS_BIP32_IMPORT_SEED = (byte) 0x6C;
|
||||
public static final byte INS_BIP32_RESET_SEED = (byte) 0x77;
|
||||
public static final byte INS_BIP32_GET_AUTHENTIKEY = (byte) 0x73;
|
||||
public static final byte INS_BIP32_SET_AUTHENTIKEY_PUBKEY = (byte) 0x75;
|
||||
public static final byte INS_BIP32_GET_EXTENDED_KEY = (byte) 0x6D;
|
||||
public static final byte INS_BIP32_SET_EXTENDED_PUBKEY = (byte) 0x74;
|
||||
public static final byte INS_SIGN_MESSAGE = (byte) 0x6E;
|
||||
public static final byte INS_SIGN_SHORT_MESSAGE = (byte) 0x72;
|
||||
public static final byte INS_SIGN_TRANSACTION = (byte) 0x6F;
|
||||
public static final byte INS_PARSE_TRANSACTION = (byte) 0x71;
|
||||
public static final byte INS_CRYPT_TRANSACTION_2FA = (byte) 0x76;
|
||||
public static final byte INS_SET_2FA_KEY = (byte) 0x79;
|
||||
public static final byte INS_RESET_2FA_KEY = (byte) 0x78;
|
||||
public static final byte INS_SIGN_TRANSACTION_HASH = (byte) 0x7A;
|
||||
// secure channel
|
||||
public static final byte INS_INIT_SECURE_CHANNEL = (byte) 0x81;
|
||||
public static final byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82;
|
||||
// secure import from SeedKeeper
|
||||
public static final byte INS_IMPORT_ENCRYPTED_SECRET = (byte) 0xAC;
|
||||
public static final byte INS_IMPORT_TRUSTED_PUBKEY = (byte) 0xAA;
|
||||
public static final byte INS_EXPORT_TRUSTED_PUBKEY = (byte) 0xAB;
|
||||
public static final byte INS_EXPORT_AUTHENTIKEY = (byte) 0xAD;
|
||||
// Personalization PKI support
|
||||
public static final byte INS_IMPORT_PKI_CERTIFICATE = (byte) 0x92;
|
||||
public static final byte INS_EXPORT_PKI_CERTIFICATE = (byte) 0x93;
|
||||
public static final byte INS_SIGN_PKI_CSR = (byte) 0x94;
|
||||
public static final byte INS_EXPORT_PKI_PUBKEY = (byte) 0x98;
|
||||
public static final byte INS_LOCK_PKI = (byte) 0x99;
|
||||
public static final byte INS_CHALLENGE_RESPONSE_PKI = (byte) 0x9A;
|
||||
// reset to factory settings
|
||||
public static final byte INS_RESET_TO_FACTORY = (byte) 0xFF;
|
||||
|
||||
/****************************************
|
||||
* Error codes *
|
||||
****************************************/
|
||||
|
||||
/**
|
||||
* Entered PIN is not correct
|
||||
*/
|
||||
public static final short SW_PIN_FAILED = (short) 0x63C0;// includes number of tries remaining
|
||||
///** DEPRECATED - Entered PIN is not correct */
|
||||
//public static final short SW_AUTH_FAILED = (short) 0x9C02;
|
||||
/**
|
||||
* Required operation is not allowed in actual circumstances
|
||||
*/
|
||||
public static final short SW_OPERATION_NOT_ALLOWED = (short) 0x9C03;
|
||||
/**
|
||||
* Required setup is not not done
|
||||
*/
|
||||
public static final short SW_SETUP_NOT_DONE = (short) 0x9C04;
|
||||
/**
|
||||
* Required setup is already done
|
||||
*/
|
||||
public static final short SW_SETUP_ALREADY_DONE = (short) 0x9C07;
|
||||
/**
|
||||
* Required feature is not (yet) supported
|
||||
*/
|
||||
public static final short SW_UNSUPPORTED_FEATURE = (short) 0x9C05;
|
||||
/**
|
||||
* Required operation was not authorized because of a lack of privileges
|
||||
*/
|
||||
public static final short SW_UNAUTHORIZED = (short) 0x9C06;
|
||||
/**
|
||||
* Algorithm specified is not correct
|
||||
*/
|
||||
public static final short SW_INCORRECT_ALG = (short) 0x9C09;
|
||||
|
||||
/**
|
||||
* There have been memory problems on the card
|
||||
*/
|
||||
public static final short SW_NO_MEMORY_LEFT = (short) 0x9C01;
|
||||
///** DEPRECATED - Required object is missing */
|
||||
//public static final short SW_OBJECT_NOT_FOUND= (short) 0x9C07;
|
||||
|
||||
/**
|
||||
* Incorrect P1 parameter
|
||||
*/
|
||||
public static final short SW_INCORRECT_P1 = (short) 0x9C10;
|
||||
/**
|
||||
* Incorrect P2 parameter
|
||||
*/
|
||||
public static final short SW_INCORRECT_P2 = (short) 0x9C11;
|
||||
/**
|
||||
* Invalid input parameter to command
|
||||
*/
|
||||
public static final short SW_INVALID_PARAMETER = (short) 0x9C0F;
|
||||
|
||||
/**
|
||||
* Eckeys initialized
|
||||
*/
|
||||
public static final short SW_ECKEYS_INITIALIZED_KEY = (short) 0x9C1A;
|
||||
|
||||
/**
|
||||
* Verify operation detected an invalid signature
|
||||
*/
|
||||
public static final short SW_SIGNATURE_INVALID = (short) 0x9C0B;
|
||||
/**
|
||||
* Operation has been blocked for security reason
|
||||
*/
|
||||
public static final short SW_IDENTITY_BLOCKED = (short) 0x9C0C;
|
||||
/**
|
||||
* For debugging purposes
|
||||
*/
|
||||
public static final short SW_INTERNAL_ERROR = (short) 0x9CFF;
|
||||
/**
|
||||
* Very low probability error
|
||||
*/
|
||||
public static final short SW_BIP32_DERIVATION_ERROR = (short) 0x9C0E;
|
||||
/**
|
||||
* Incorrect initialization of method
|
||||
*/
|
||||
public static final short SW_INCORRECT_INITIALIZATION = (short) 0x9C13;
|
||||
/**
|
||||
* Bip32 seed is not initialized
|
||||
*/
|
||||
public static final short SW_BIP32_UNINITIALIZED_SEED = (short) 0x9C14;
|
||||
/**
|
||||
* Bip32 seed is already initialized (must be reset before change)
|
||||
*/
|
||||
public static final short SW_BIP32_INITIALIZED_SEED = (short) 0x9C17;
|
||||
//** DEPRECATED - Bip32 authentikey pubkey is not initialized*/
|
||||
//public static final short SW_BIP32_UNINITIALIZED_AUTHENTIKEY_PUBKEY= (short) 0x9C16;
|
||||
/**
|
||||
* Incorrect transaction hash
|
||||
*/
|
||||
public static final short SW_INCORRECT_TXHASH = (short) 0x9C15;
|
||||
|
||||
/**
|
||||
* 2FA already initialized
|
||||
*/
|
||||
public static final short SW_2FA_INITIALIZED_KEY = (short) 0x9C18;
|
||||
/**
|
||||
* 2FA uninitialized
|
||||
*/
|
||||
public static final short SW_2FA_UNINITIALIZED_KEY = (short) 0x9C19;
|
||||
|
||||
/**
|
||||
* HMAC errors
|
||||
*/
|
||||
public static final short SW_HMAC_UNSUPPORTED_KEYSIZE = (short) 0x9c1E;
|
||||
public static final short SW_HMAC_UNSUPPORTED_MSGSIZE = (short) 0x9c1F;
|
||||
|
||||
/**
|
||||
* Secure channel
|
||||
*/
|
||||
public static final short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20;
|
||||
public static final short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21;
|
||||
public static final short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22;
|
||||
public static final short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23;
|
||||
|
||||
/**
|
||||
* Secret data is too long for import
|
||||
**/
|
||||
public static final short SW_IMPORTED_DATA_TOO_LONG = (short) 0x9C32;
|
||||
/**
|
||||
* Wrong HMAC when importing Secret through Secure import
|
||||
**/
|
||||
public static final short SW_SECURE_IMPORT_WRONG_MAC = (short) 0x9C33;
|
||||
/**
|
||||
* Wrong Fingerprint when importing Secret through Secure import
|
||||
**/
|
||||
public static final short SW_SECURE_IMPORT_WRONG_FINGERPRINT = (short) 0x9C34;
|
||||
/**
|
||||
* No Trusted Pubkey when importing Secret through Secure import
|
||||
**/
|
||||
public static final short SW_SECURE_IMPORT_NO_TRUSTEDPUBKEY = (short) 0x9C35;
|
||||
|
||||
/**
|
||||
* PKI perso error
|
||||
*/
|
||||
public static final short SW_PKI_ALREADY_LOCKED = (short) 0x9C40;
|
||||
/**
|
||||
* CARD HAS BEEN RESET TO FACTORY
|
||||
*/
|
||||
public static final short SW_RESET_TO_FACTORY = (short) 0xFF00;
|
||||
/**
|
||||
* For instructions that have been deprecated
|
||||
*/
|
||||
public static final short SW_INS_DEPRECATED = (short) 0x9C26;
|
||||
/**
|
||||
* For debugging purposes 2
|
||||
*/
|
||||
public static final short SW_DEBUG_FLAG = (short) 0x9FFF;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Keypath object to be used with the SatochipCommandSet
|
||||
*/
|
||||
public class KeyPath {
|
||||
private final byte[] data;
|
||||
|
||||
/**
|
||||
* Parses a keypath into a byte array to be used with the SatochipCommandSet object.
|
||||
* <p>
|
||||
* A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
|
||||
* <p>
|
||||
* The first component should be "m", indicating the master key.
|
||||
* <p>
|
||||
* All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
|
||||
* which indicates an hardened key.
|
||||
* <p>
|
||||
* An example of a valid path is "m/44'/0'/0'/0/0"
|
||||
*
|
||||
* @param keypath the keypath as a string
|
||||
*/
|
||||
public KeyPath(String keypath) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(keypath, "/");
|
||||
|
||||
String sourceOrFirstElement = tokenizer.nextToken(); // m
|
||||
|
||||
int componentCount = tokenizer.countTokens();
|
||||
if(componentCount > 10) {
|
||||
throw new IllegalArgumentException("Too many components");
|
||||
}
|
||||
|
||||
data = new byte[4 * componentCount];
|
||||
|
||||
for(int i = 0; i < componentCount; i++) {
|
||||
long component = parseComponent(tokenizer.nextToken());
|
||||
writeComponent(component, i);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyPath(byte[] data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
private long parseComponent(String num) {
|
||||
long sign;
|
||||
|
||||
if(num.endsWith("'")) {
|
||||
sign = 0x80000000L;
|
||||
num = num.substring(0, (num.length() - 1));
|
||||
} else {
|
||||
sign = 0L;
|
||||
}
|
||||
|
||||
if(num.startsWith("+") || num.startsWith("-")) {
|
||||
throw new NumberFormatException("No sign allowed");
|
||||
}
|
||||
return (sign | Long.parseLong(num));
|
||||
}
|
||||
|
||||
private void writeComponent(long component, int i) {
|
||||
int off = (i * 4);
|
||||
data[off] = (byte) ((component >> 24) & 0xff);
|
||||
data[off + 1] = (byte) ((component >> 16) & 0xff);
|
||||
data[off + 2] = (byte) ((component >> 8) & 0xff);
|
||||
data[off + 3] = (byte) (component & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte encoded key path.
|
||||
*
|
||||
* @return byte encoded key path
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuffer sb = new StringBuffer();
|
||||
sb.append('m');
|
||||
|
||||
for(int i = 0; i < this.data.length; i += 4) {
|
||||
sb.append('/');
|
||||
appendComponent(sb, i);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendComponent(StringBuffer sb, int i) {
|
||||
int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff);
|
||||
sb.append(num);
|
||||
|
||||
if((this.data[i] & 0x80) == 0x80) {
|
||||
sb.append('\'');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||
import com.sparrowwallet.drongo.crypto.SchnorrSignature;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.control.CardImportPane;
|
||||
import com.sparrowwallet.sparrow.io.CardApi;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class SatoCardApi extends CardApi {
|
||||
private static final Logger log = LoggerFactory.getLogger(SatoCardApi.class);
|
||||
|
||||
private final WalletModel cardType;
|
||||
private final SatochipCommandSet cardProtocol;
|
||||
private final String pin;
|
||||
private String basePath = null;
|
||||
|
||||
public SatoCardApi(WalletModel cardType, String pin) throws CardException {
|
||||
this.cardType = cardType;
|
||||
this.cardProtocol = new SatochipCommandSet();
|
||||
this.pin = pin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInitialized() throws CardException {
|
||||
SatoCardStatus cardStatus = this.getStatus();
|
||||
return cardStatus.isInitialized(); // setupDone && isSeeded
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public void initialize(int slot, byte[] seedBytes) throws CardException {
|
||||
// TODO check device certificate
|
||||
SatoCardStatus cardStatus = this.getStatus();
|
||||
|
||||
APDUResponse rapdu;
|
||||
if(!cardStatus.isSetupDone()) {
|
||||
byte maxPinTries = 5;
|
||||
rapdu = this.cardProtocol.cardSetup(maxPinTries, pin.getBytes(StandardCharsets.UTF_8));
|
||||
// check ok
|
||||
}
|
||||
|
||||
if(!cardStatus.isSeeded()) {
|
||||
// check pin
|
||||
rapdu = this.cardProtocol.cardVerifyPIN(0, pin);
|
||||
// todo: check PIN response
|
||||
|
||||
rapdu = this.cardProtocol.cardBip32ImportSeed(seedBytes);
|
||||
// check ok
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getCardType() throws CardException {
|
||||
return WalletModel.SATOCHIP;
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public int getCurrentSlot() throws CardException {
|
||||
throw new CardException("Satochip does not support slots");
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public ScriptType getDefaultScriptType() {
|
||||
return ScriptType.P2WPKH;
|
||||
}
|
||||
|
||||
SatoCardStatus getStatus() throws CardException {
|
||||
return this.cardProtocol.getApplicationStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Void> getAuthDelayService() throws CardException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresBackup() throws CardException {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<String> getBackupService() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean changePin(String newPin) throws CardException {
|
||||
this.cardProtocol.cardChangePIN((byte) 0, this.pin, newPin);
|
||||
return true;
|
||||
}
|
||||
|
||||
void setDerivation(List<ChildNumber> derivation) throws CardException {
|
||||
this.basePath = KeyDerivation.writePath(derivation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Void> getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
|
||||
return new CardInitializationService(seedBytes, messageProperty);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
return new CardImportPane.CardImportService(new Satochip(), pin, derivation, messageProperty);
|
||||
}
|
||||
|
||||
/*
|
||||
* Satochip derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub.
|
||||
* the basePath (from masterseed to xpub) is only provided in Satochip.java:getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty)
|
||||
* In SatoCardApi:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field.
|
||||
* currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough
|
||||
*/
|
||||
@Override
|
||||
public Keystore getKeystore() throws CardException {
|
||||
this.cardProtocol.cardVerifyPIN(0, pin);
|
||||
String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath());
|
||||
ExtendedKey.Header xtype = Network.get().getXpubHeader();
|
||||
String xpub = this.cardProtocol.cardBip32GetXpub(keyDerivationString, xtype);
|
||||
ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub);
|
||||
String masterXpub = this.cardProtocol.cardBip32GetXpub("m", xtype);
|
||||
ExtendedKey masterExtendedKey = ExtendedKey.fromDescriptor(masterXpub);
|
||||
String masterFingerprint = Utils.bytesToHex(masterExtendedKey.getKey().getFingerprint());
|
||||
KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString);
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(WalletModel.SATOCHIP.toDisplayString());
|
||||
keystore.setKeyDerivation(keyDerivation);
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
keystore.setWalletModel(WalletModel.SATOCHIP);
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
|
||||
return new SignService(wallet, psbt, messageProperty);
|
||||
}
|
||||
|
||||
void sign(Wallet wallet, PSBT psbt) throws CardException {
|
||||
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
|
||||
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
|
||||
if(!psbtInput.isSigned()) {
|
||||
WalletNode signingNode = signingNodes.get(psbtInput);
|
||||
String fullPath = null;
|
||||
List<Keystore> keystores = wallet.getKeystores();
|
||||
for(int i = 0; i < keystores.size(); i++) {
|
||||
Keystore keystore = keystores.get(i);
|
||||
WalletModel walletModel = keystore.getWalletModel();
|
||||
if(walletModel == WalletModel.SATOCHIP) {
|
||||
String basePath = keystore.getKeyDerivation().getDerivationPath();
|
||||
String extendedPath = signingNode.getDerivationPath().substring(1);
|
||||
fullPath = basePath + extendedPath;
|
||||
keystore.getPubKey(signingNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
psbtInput.sign(new CardPSBTInputSigner(signingNode, fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
return new SignMessageService(message, scriptType, derivation, messageProperty);
|
||||
}
|
||||
|
||||
String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
|
||||
String fullpath = KeyDerivation.writePath(derivation);
|
||||
cardProtocol.cardVerifyPIN(0, pin);
|
||||
|
||||
// 2FA is optionnal, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
|
||||
SatoCardStatus cardStatus = this.getStatus();
|
||||
if(cardStatus.needs2FA()) {
|
||||
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
|
||||
}
|
||||
|
||||
// derive the correct key in satochip
|
||||
APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullpath);
|
||||
// recover pubkey
|
||||
SatochipParser parser = new SatochipParser();
|
||||
byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu);
|
||||
ECKey pubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]);
|
||||
|
||||
// sign msg
|
||||
return pubkey.signMessage(message, scriptType, hash -> {
|
||||
try {
|
||||
// do the signature with satochip
|
||||
byte keynbr = (byte) 0xFF;
|
||||
byte[] chalresponse = null;
|
||||
APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse);
|
||||
byte[] sigBytes = rapdu2.getData();
|
||||
return ECDSASignature.decodeFromDER(sigBytes);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
|
||||
throw new UnsupportedOperationException("Satochip does not support private key export");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Address> getAddressService(StringProperty messageProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
cardProtocol.cardDisconnect();
|
||||
}
|
||||
|
||||
public class CardInitializationService extends Service<Void> {
|
||||
private final byte[] seedBytes;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
|
||||
this.seedBytes = seedBytes;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected Void call() throws Exception {
|
||||
if(seedBytes == null) {
|
||||
throw new CardException("Failed to initialize Satochip - no seed provided");
|
||||
}
|
||||
|
||||
initialize(0, seedBytes);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class SignService extends Service<PSBT> {
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
|
||||
this.wallet = wallet;
|
||||
this.psbt = psbt;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<PSBT> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected PSBT call() throws Exception {
|
||||
sign(wallet, psbt);
|
||||
return psbt;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class CardPSBTInputSigner implements PSBTInputSigner {
|
||||
private final WalletNode signingNode;
|
||||
private final String fullPath;
|
||||
private ECKey pubkey;
|
||||
|
||||
// todo: provide derivationpath instead of WalletNode??
|
||||
public CardPSBTInputSigner(WalletNode signingNode, String fullPath) {
|
||||
this.signingNode = signingNode;
|
||||
this.fullPath = fullPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
|
||||
try {
|
||||
// 2FA is optional, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
|
||||
SatoCardStatus cardStatus = getStatus();
|
||||
if(cardStatus.needs2FA()) {
|
||||
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
|
||||
}
|
||||
|
||||
// verify PIN
|
||||
APDUResponse rapdu0 = cardProtocol.cardVerifyPIN(0, pin);
|
||||
|
||||
// derive the correct key in satochip and recover pubkey
|
||||
APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullPath);
|
||||
SatochipParser parser = new SatochipParser();
|
||||
byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu);
|
||||
ECKey internalPubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]);
|
||||
|
||||
if(signatureType == TransactionSignature.Type.ECDSA) {
|
||||
// for ECDSA, pubkey is the same as internalPubkey
|
||||
pubkey = internalPubkey;
|
||||
// do the signature with satochip
|
||||
byte keynbr = (byte) 0xFF;
|
||||
byte[] chalresponse = null;
|
||||
APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse);
|
||||
byte[] sigBytes = rapdu2.getData();
|
||||
ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(sigBytes).toCanonicalised();
|
||||
TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash);
|
||||
|
||||
// verify
|
||||
boolean isCorrect = pubkey.verify(hash, txSig);
|
||||
return txSig;
|
||||
} else {
|
||||
// Satochip supports schnorr signature only for version >= 0.14 !
|
||||
byte[] versionBytes = cardStatus.getCardVersion();
|
||||
int protocolVersion = versionBytes[0] * 256 + versionBytes[1];
|
||||
if(protocolVersion < (256 * 0 + 14)) {
|
||||
throw new CardException(WalletModel.SATOCHIP.toDisplayString() + " (with version below v0.14) cannot sign Taproot transactions");
|
||||
}
|
||||
|
||||
// tweak the bip32 key according to bip341
|
||||
byte keynbr = (byte) 0xFF;
|
||||
byte[] tweak = null;
|
||||
APDUResponse rapduTweak = cardProtocol.cardTaprootTweakPrivkey(keynbr, tweak);
|
||||
byte[] tweakedPubkeyBytes = new byte[65];
|
||||
System.arraycopy(rapduTweak.getData(), 2, tweakedPubkeyBytes, 0, 65);
|
||||
pubkey = ECKey.fromPublicOnly(tweakedPubkeyBytes);
|
||||
|
||||
byte[] chalresponse = null;
|
||||
APDUResponse rapdu2 = cardProtocol.cardSignSchnorrHash(keynbr, hash.getBytes(), chalresponse);
|
||||
byte[] sigBytes = rapdu2.getData();
|
||||
SchnorrSignature schnorrSig = SchnorrSignature.decode(sigBytes);
|
||||
TransactionSignature txSig = new TransactionSignature(schnorrSig, sigHash);
|
||||
|
||||
// verify sig with outputPubkey...
|
||||
boolean isCorrect2 = pubkey.verify(hash, txSig);
|
||||
|
||||
return txSig; //new TransactionSignature(schnorrSig, sigHash);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ECKey getPubKey() {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
public class SignMessageService extends Service<String> {
|
||||
private final String message;
|
||||
private final ScriptType scriptType;
|
||||
private final List<ChildNumber> derivation;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
this.message = message;
|
||||
this.scriptType = scriptType;
|
||||
this.derivation = derivation;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<String> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected String call() throws Exception {
|
||||
return signMessage(message, scriptType, derivation);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
/**
|
||||
* Parses the result of a GET STATUS command retrieving application status.
|
||||
*/
|
||||
public class SatoCardStatus {
|
||||
private boolean setup_done = false;
|
||||
private boolean is_seeded = false;
|
||||
private boolean needs_secure_channel = false;
|
||||
private boolean needs_2FA = false;
|
||||
|
||||
private byte protocol_major_version = (byte) 0;
|
||||
private byte protocol_minor_version = (byte) 0;
|
||||
private byte applet_major_version = (byte) 0;
|
||||
private byte applet_minor_version = (byte) 0;
|
||||
|
||||
private byte PIN0_remaining_tries = (byte) 0;
|
||||
private byte PUK0_remaining_tries = (byte) 0;
|
||||
private byte PIN1_remaining_tries = (byte) 0;
|
||||
private byte PUK1_remaining_tries = (byte) 0;
|
||||
|
||||
private int protocol_version = 0; //(d["protocol_major_version"]<<8)+d["protocol_minor_version"]
|
||||
|
||||
/**
|
||||
* Constructor from TLV data
|
||||
*
|
||||
* @param rapdu the TLV data
|
||||
* @throws IllegalArgumentException if the TLV does not follow the expected format
|
||||
*/
|
||||
public SatoCardStatus(APDUResponse rapdu) {
|
||||
int sw = rapdu.getSw();
|
||||
|
||||
if(sw == 0x9000) {
|
||||
byte[] data = rapdu.getData();
|
||||
protocol_major_version = data[0];
|
||||
protocol_minor_version = data[1];
|
||||
applet_major_version = data[2];
|
||||
applet_minor_version = data[3];
|
||||
protocol_version = (protocol_major_version << 8) + protocol_minor_version;
|
||||
|
||||
if(data.length >= 8) {
|
||||
PIN0_remaining_tries = data[4];
|
||||
PUK0_remaining_tries = data[5];
|
||||
PIN1_remaining_tries = data[6];
|
||||
PUK1_remaining_tries = data[7];
|
||||
needs_2FA = false; //default value
|
||||
}
|
||||
if(data.length >= 9) {
|
||||
needs_2FA = data[8] != 0X00;
|
||||
}
|
||||
if(data.length >= 10) {
|
||||
is_seeded = data[9] != 0X00;
|
||||
}
|
||||
if(data.length >= 11) {
|
||||
setup_done = data[10] != 0X00;
|
||||
} else {
|
||||
setup_done = true;
|
||||
}
|
||||
if(data.length >= 12) {
|
||||
needs_secure_channel = data[11] != 0X00;
|
||||
} else {
|
||||
needs_secure_channel = false;
|
||||
needs_2FA = false; //default value
|
||||
}
|
||||
} else if(sw == 0x9c04) {
|
||||
setup_done = false;
|
||||
is_seeded = false;
|
||||
needs_secure_channel = false;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid getStatus data");
|
||||
}
|
||||
}
|
||||
|
||||
// getters
|
||||
public boolean isSeeded() {
|
||||
return is_seeded;
|
||||
}
|
||||
|
||||
public boolean isSetupDone() {
|
||||
return setup_done;
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return (setup_done && is_seeded);
|
||||
}
|
||||
|
||||
public boolean needsSecureChannel() {
|
||||
return needs_secure_channel;
|
||||
}
|
||||
|
||||
public boolean needs2FA() {
|
||||
return needs_2FA;
|
||||
}
|
||||
|
||||
public byte getPin0RemainingCounter() {
|
||||
return PIN0_remaining_tries;
|
||||
}
|
||||
|
||||
public byte[] getCardVersion() {
|
||||
byte[] versionBytes = new byte[4];
|
||||
versionBytes[0] = protocol_major_version;
|
||||
versionBytes[1] = protocol_minor_version;
|
||||
versionBytes[2] = applet_major_version;
|
||||
versionBytes[3] = applet_minor_version;
|
||||
return versionBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "setup_done: " + setup_done + "\n" +
|
||||
"is_seeded: " + is_seeded + "\n" +
|
||||
"needs_2FA: " + needs_2FA + "\n" +
|
||||
"needs_secure_channel: " + needs_secure_channel + "\n" +
|
||||
"protocol_major_version: " + protocol_major_version + "\n" +
|
||||
"protocol_minor_version: " + protocol_minor_version + "\n" +
|
||||
"applet_major_version: " + applet_major_version + "\n" +
|
||||
"applet_minor_version: " + applet_minor_version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
import javax.smartcardio.CardChannel;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
public class SatoCardTransport {
|
||||
private static final Logger log = LoggerFactory.getLogger(SatoCardTransport.class);
|
||||
|
||||
private final Card connection;
|
||||
|
||||
SatoCardTransport(byte[] appletAid) throws CardException {
|
||||
TerminalFactory tf = TerminalFactory.getDefault();
|
||||
List<CardTerminal> terminals = tf.terminals().list();
|
||||
if(terminals.isEmpty()) {
|
||||
throw new IllegalStateException("No reader connected");
|
||||
}
|
||||
|
||||
Card connection = null;
|
||||
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
|
||||
try {
|
||||
connection = getConnection(iter.next(), appletAid);
|
||||
break;
|
||||
} catch(CardException e) {
|
||||
if(!iter.hasNext()) {
|
||||
log.info(e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException {
|
||||
Card connection = cardTerminal.connect("*");
|
||||
|
||||
CardChannel cardChannel = connection.getBasicChannel();
|
||||
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid));
|
||||
if(resp.getSW() != APDUResponse.SW_OK) {
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
APDUResponse send(APDUCommand capdu) throws CardException {
|
||||
javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel();
|
||||
|
||||
CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData());
|
||||
ResponseAPDU resp = cardChannel.transmit(cmd);
|
||||
|
||||
return new APDUResponse(resp.getBytes());
|
||||
}
|
||||
|
||||
void disconnect() throws CardException {
|
||||
connection.disconnect(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import javafx.beans.property.StringProperty;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.util.List;
|
||||
|
||||
public class Satochip implements KeystoreCardImport {
|
||||
@Override
|
||||
public boolean isInitialized() throws CardException {
|
||||
SatoCardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new SatoCardApi(WalletModel.SATOCHIP, null);
|
||||
return cardApi.isInitialized();
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
|
||||
if(pin.length() < 4) {
|
||||
throw new CardException("PIN too short.");
|
||||
}
|
||||
|
||||
if(pin.length() > 16) {
|
||||
throw new CardException("PIN too long.");
|
||||
}
|
||||
|
||||
SatoCardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin);
|
||||
SatoCardStatus cardStatus = cardApi.getStatus();
|
||||
if(cardStatus.isInitialized()) {
|
||||
throw new IllegalStateException("Card is already initialized.");
|
||||
}
|
||||
|
||||
cardApi.initialize(0, entropy);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
|
||||
if(pin.length() < 4) {
|
||||
throw new ImportException("PIN too short.");
|
||||
}
|
||||
|
||||
if(pin.length() > 16) {
|
||||
throw new ImportException("PIN too long.");
|
||||
}
|
||||
|
||||
SatoCardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin);
|
||||
SatoCardStatus cardStatus = cardApi.getStatus();
|
||||
if(!cardStatus.isInitialized()) {
|
||||
throw new IllegalStateException("Card is not initialized.");
|
||||
}
|
||||
cardApi.setDerivation(derivation);
|
||||
return cardApi.getKeystore();
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import the keystore from your Satochip by inserting or placing it on the card reader.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Satochip";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.SATOCHIP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.Base58;
|
||||
|
||||
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import static com.sparrowwallet.sparrow.io.satochip.Constants.*;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
|
||||
/**
|
||||
* This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md
|
||||
* file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some
|
||||
* pre/post processing.
|
||||
*/
|
||||
public class SatochipCommandSet {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SatochipCommandSet.class);
|
||||
|
||||
private final SatoCardTransport cardTransport;
|
||||
private final SecureChannelSession secureChannel;
|
||||
private SatoCardStatus status;
|
||||
private SatochipParser parser = null;
|
||||
|
||||
private String pinCached = null;
|
||||
|
||||
public static final byte[] SATOCHIP_AID = Utils.hexToBytes("5361746f43686970"); //SatoChip
|
||||
|
||||
/**
|
||||
* Creates a SatochipCommandSet using the given APDU Channel
|
||||
*/
|
||||
public SatochipCommandSet() throws CardException {
|
||||
this.cardTransport = new SatoCardTransport(SATOCHIP_AID);
|
||||
this.secureChannel = new SecureChannelSession();
|
||||
this.parser = new SatochipParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
|
||||
* command has been sent using this command set.
|
||||
*
|
||||
* @return the application info object
|
||||
*/
|
||||
public SatoCardStatus getApplicationStatus() {
|
||||
if(this.status == null) {
|
||||
this.cardGetStatus();
|
||||
}
|
||||
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* AUTHENTIKEY *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardTransmit(APDUCommand plainApdu) {
|
||||
// we try to transmit the APDU until we receive the answer or we receive an unrecoverable error
|
||||
boolean isApduTransmitted = false;
|
||||
do {
|
||||
try {
|
||||
byte[] apduBytes = plainApdu.serialize();
|
||||
byte ins = apduBytes[1];
|
||||
boolean isEncrypted = false;
|
||||
|
||||
// check if status available
|
||||
if(status == null) {
|
||||
APDUCommand statusCapdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]);
|
||||
APDUResponse statusRapdu = this.cardTransport.send(statusCapdu);
|
||||
status = new SatoCardStatus(statusRapdu);
|
||||
}
|
||||
|
||||
APDUCommand capdu = plainApdu;
|
||||
if(status.needsSecureChannel() && (ins != 0xA4) && (ins != 0x81) && (ins != 0x82) && (ins != INS_GET_STATUS)) {
|
||||
if(!secureChannel.initializedSecureChannel()) {
|
||||
// get card's public key
|
||||
APDUResponse secChannelRapdu = this.cardInitiateSecureChannel();
|
||||
byte[] pubkey = this.parser.parseInitiateSecureChannel(secChannelRapdu);
|
||||
// setup secure channel
|
||||
this.secureChannel.initiateSecureChannel(pubkey);
|
||||
}
|
||||
// encrypt apdu
|
||||
capdu = secureChannel.encryptSecureChannel(plainApdu);
|
||||
isEncrypted = true;
|
||||
}
|
||||
|
||||
APDUResponse rapdu = this.cardTransport.send(capdu);
|
||||
int sw12 = rapdu.getSw();
|
||||
|
||||
// check answer
|
||||
if(sw12 == 0x9000) { // ok!
|
||||
if(isEncrypted) {
|
||||
// decrypt
|
||||
rapdu = secureChannel.decryptSecureChannel(rapdu);
|
||||
}
|
||||
isApduTransmitted = true; // leave loop
|
||||
return rapdu;
|
||||
}
|
||||
// PIN authentication is required
|
||||
else if(sw12 == 0x9C06) {
|
||||
//cardVerifyPIN();
|
||||
log.error("Error, Satochip PIN required");
|
||||
throw new CardAuthorizationException("PIN is required");
|
||||
}
|
||||
// SecureChannel is not initialized
|
||||
else if(sw12 == 0x9C21) {
|
||||
log.error("Error, Satochip secure channel required");
|
||||
secureChannel.resetSecureChannel();
|
||||
} else {
|
||||
// cannot resolve issue at this point
|
||||
isApduTransmitted = true; // leave loop
|
||||
return rapdu;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("Error transmitting Satochip command set" + e);
|
||||
return new APDUResponse(new byte[0], (byte) 0x00, (byte) 0x00); // return empty APDUResponse
|
||||
}
|
||||
} while(!isApduTransmitted);
|
||||
|
||||
return new APDUResponse(new byte[0], (byte) 0x00, (byte) 0x00); // should not happen
|
||||
}
|
||||
|
||||
public void cardDisconnect() {
|
||||
secureChannel.resetSecureChannel();
|
||||
status = null;
|
||||
pinCached = null;
|
||||
try {
|
||||
cardTransport.disconnect();
|
||||
} catch(CardException e) {
|
||||
log.error("Error disconnecting Satochip" + e);
|
||||
}
|
||||
}
|
||||
|
||||
public void cardGetStatus() {
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]);
|
||||
APDUResponse respApdu = this.cardTransmit(plainApdu);
|
||||
this.status = new SatoCardStatus(respApdu);
|
||||
}
|
||||
|
||||
public APDUResponse cardInitiateSecureChannel() throws CardException {
|
||||
byte[] pubkey = secureChannel.getPublicKey();
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_INIT_SECURE_CHANNEL, 0x00, 0x00, pubkey);
|
||||
|
||||
return this.cardTransport.send(plainApdu);
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* CARD MGMT *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardSetup(byte pin_tries0, byte[] pin0) {
|
||||
// use random values for pin1, ublk0, ublk1
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] ublk0 = new byte[8];
|
||||
byte[] ublk1 = new byte[8];
|
||||
byte[] pin1 = new byte[8];
|
||||
random.nextBytes(ublk0);
|
||||
random.nextBytes(ublk1);
|
||||
random.nextBytes(pin1);
|
||||
|
||||
byte ublk_tries0 = (byte) 0x01;
|
||||
byte ublk_tries1 = (byte) 0x01;
|
||||
byte pin_tries1 = (byte) 0x01;
|
||||
|
||||
return cardSetup(pin_tries0, ublk_tries0, pin0, ublk0, pin_tries1, ublk_tries1, pin1, ublk1);
|
||||
}
|
||||
|
||||
public APDUResponse cardSetup(byte pin_tries0, byte ublk_tries0, byte[] pin0, byte[] ublk0,
|
||||
byte pin_tries1, byte ublk_tries1, byte[] pin1, byte[] ublk1) {
|
||||
|
||||
byte[] pin = {0x4D, 0x75, 0x73, 0x63, 0x6C, 0x65, 0x30, 0x30}; //default pin
|
||||
byte cla = (byte) 0xB0;
|
||||
byte ins = INS_SETUP;
|
||||
byte p1 = 0;
|
||||
byte p2 = 0;
|
||||
|
||||
// data=[pin_length(1) | pin |
|
||||
// pin_tries0(1) | ublk_tries0(1) | pin0_length(1) | pin0 | ublk0_length(1) | ublk0 |
|
||||
// pin_tries1(1) | ublk_tries1(1) | pin1_length(1) | pin1 | ublk1_length(1) | ublk1 |
|
||||
// memsize(2) | memsize2(2) | ACL(3) |
|
||||
// option_flags(2) | hmacsha160_key(20) | amount_limit(8)]
|
||||
int optionsize = 0;
|
||||
int option_flags = 0; // do not use option (mostly deprecated)
|
||||
int offset = 0;
|
||||
int datasize = 16 + pin.length + pin0.length + pin1.length + ublk0.length + ublk1.length + optionsize;
|
||||
byte[] data = new byte[datasize];
|
||||
|
||||
data[offset++] = (byte) pin.length;
|
||||
System.arraycopy(pin, 0, data, offset, pin.length);
|
||||
offset += pin.length;
|
||||
// pin0 & ublk0
|
||||
data[offset++] = pin_tries0;
|
||||
data[offset++] = ublk_tries0;
|
||||
data[offset++] = (byte) pin0.length;
|
||||
System.arraycopy(pin0, 0, data, offset, pin0.length);
|
||||
offset += pin0.length;
|
||||
data[offset++] = (byte) ublk0.length;
|
||||
System.arraycopy(ublk0, 0, data, offset, ublk0.length);
|
||||
offset += ublk0.length;
|
||||
// pin1 & ublk1
|
||||
data[offset++] = pin_tries1;
|
||||
data[offset++] = ublk_tries1;
|
||||
data[offset++] = (byte) pin1.length;
|
||||
System.arraycopy(pin1, 0, data, offset, pin1.length);
|
||||
offset += pin1.length;
|
||||
data[offset++] = (byte) ublk1.length;
|
||||
System.arraycopy(ublk1, 0, data, offset, ublk1.length);
|
||||
offset += ublk1.length;
|
||||
|
||||
// memsize default (deprecated)
|
||||
data[offset++] = (byte) 00;
|
||||
data[offset++] = (byte) 32;
|
||||
data[offset++] = (byte) 00;
|
||||
data[offset++] = (byte) 32;
|
||||
|
||||
// ACL (deprecated)
|
||||
data[offset++] = (byte) 0x01;
|
||||
data[offset++] = (byte) 0x01;
|
||||
data[offset++] = (byte) 0x01;
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(cla, ins, p1, p2, data);
|
||||
APDUResponse respApdu = this.cardTransmit(plainApdu);
|
||||
|
||||
if(respApdu.getSw() == 0x9000) {
|
||||
//setPin0(pin0); // todo: cache value...
|
||||
} else {
|
||||
log.error("Error " + respApdu.toHexString());
|
||||
}
|
||||
|
||||
return respApdu;
|
||||
}
|
||||
|
||||
|
||||
/****************************************
|
||||
* PIN MGMT *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardVerifyPIN() throws CardException {
|
||||
return this.cardVerifyPIN((byte) 0, pinCached);
|
||||
}
|
||||
|
||||
public APDUResponse cardVerifyPIN(int pinNbr, String pin) throws CardException {
|
||||
if(pin == null) {
|
||||
if(pinCached == null) {
|
||||
throw new CardException("PIN required!");
|
||||
}
|
||||
pin = this.pinCached;
|
||||
}
|
||||
|
||||
byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8);
|
||||
if(pinBytes.length > 16) {
|
||||
throw new CardException("PIN should be maximum 16 characters!");
|
||||
}
|
||||
|
||||
APDUCommand capdu = new APDUCommand(0xB0, INS_VERIFY_PIN, (byte) pinNbr, 0x00, pinBytes);
|
||||
APDUResponse rapdu = this.cardTransmit(capdu);
|
||||
|
||||
// correct PIN: cache PIN value
|
||||
int sw = rapdu.getSw();
|
||||
if(sw == 0x9000) {
|
||||
this.pinCached = pin; //set cached PIN value
|
||||
}
|
||||
// wrong PIN, get remaining tries available (since v0.11)
|
||||
else if((sw & 0xffc0) == 0x63c0) {
|
||||
this.pinCached = null; //reset cached PIN value
|
||||
int pinLeft = (sw & ~0xffc0);
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// wrong PIN (legacy before v0.11)
|
||||
else if(sw == 0x9c02) {
|
||||
this.pinCached = null; //reset cached PIN value
|
||||
SatoCardStatus cardStatus = this.getApplicationStatus();
|
||||
int pinLeft = cardStatus.getPin0RemainingCounter();
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// blocked PIN
|
||||
else if(sw == 0x9c0c) {
|
||||
throw new CardException("Card is blocked!");
|
||||
}
|
||||
|
||||
return rapdu;
|
||||
}
|
||||
|
||||
public void cardChangePIN(int pinNbr, String oldPin, String newPin) throws CardException {
|
||||
byte[] oldPinBytes = oldPin.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] newPinBytes = newPin.getBytes(StandardCharsets.UTF_8);
|
||||
int lc = 1 + oldPinBytes.length + 1 + newPinBytes.length;
|
||||
byte[] data = new byte[lc];
|
||||
|
||||
data[0] = (byte) oldPinBytes.length;
|
||||
int offset = 1;
|
||||
System.arraycopy(oldPinBytes, 0, data, offset, oldPinBytes.length);
|
||||
offset += oldPinBytes.length;
|
||||
data[offset] = (byte) newPinBytes.length;
|
||||
offset += 1;
|
||||
System.arraycopy(newPinBytes, 0, data, offset, newPinBytes.length);
|
||||
|
||||
APDUCommand capdu = new APDUCommand(0xB0, INS_CHANGE_PIN, (byte) pinNbr, 0x00, data);
|
||||
APDUResponse rapdu = this.cardTransmit(capdu);
|
||||
|
||||
// correct PIN: cache PIN value
|
||||
int sw = rapdu.getSw();
|
||||
if(sw == 0x9000) {
|
||||
this.pinCached = newPin;
|
||||
}
|
||||
// wrong PIN, get remaining tries available (since v0.11)
|
||||
else if((sw & 0xffc0) == 0x63c0) {
|
||||
int pinLeft = (sw & ~0xffc0);
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// wrong PIN (legacy before v0.11)
|
||||
else if(sw == 0x9c02) {
|
||||
SatoCardStatus cardStatus = this.getApplicationStatus();
|
||||
int pinLeft = cardStatus.getPin0RemainingCounter();
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// blocked PIN
|
||||
else if(sw == 0x9c0c) {
|
||||
throw new CardException("Card is blocked!");
|
||||
}
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* BIP32 *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardBip32ImportSeed(byte[] masterseed) {
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_IMPORT_SEED, masterseed.length, 0x00, masterseed);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
public APDUResponse cardBip32GetExtendedKey(String stringPath) {
|
||||
KeyPath keyPath = new KeyPath(stringPath);
|
||||
byte[] bytePath = keyPath.getData();
|
||||
return cardBip32GetExtendedKey(bytePath);
|
||||
}
|
||||
|
||||
public APDUResponse cardBip32GetExtendedKey(byte[] bytePath) {
|
||||
byte p1 = (byte) (bytePath.length / 4);
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_GET_EXTENDED_KEY, p1, 0x40, bytePath);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the BIP32 xpub for given path.
|
||||
*
|
||||
* Parameters:
|
||||
* path (str): the path; if given as a string, it will be converted to bytes (4 bytes for each path index)
|
||||
* xtype (str): the type of transaction such as 'standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh'
|
||||
* is_mainnet (bool): is mainnet or testnet
|
||||
*
|
||||
* Return:
|
||||
* xpub (str): the corresponding xpub value
|
||||
*/
|
||||
public String cardBip32GetXpub(String stringPath, ExtendedKey.Header xtype) throws CardException {
|
||||
// path is of the form 44'/0'/1'
|
||||
KeyPath keyPath = new KeyPath(stringPath);
|
||||
byte[] bytePath = keyPath.getData();
|
||||
int depth = bytePath.length / 4;
|
||||
|
||||
APDUResponse rapdu = this.cardBip32GetExtendedKey(bytePath);
|
||||
byte[][] extendedkey = this.parser.parseBip32GetExtendedKey(rapdu);
|
||||
|
||||
byte[] fingerprint = new byte[4];
|
||||
byte[] childNumber = new byte[4];
|
||||
if(depth == 0) { //masterkey
|
||||
// fingerprint and childnumber set to all-zero bytes by default
|
||||
//fingerprint= bytes([0,0,0,0])
|
||||
//childNumber= bytes([0,0,0,0])
|
||||
} else { //get parent info
|
||||
byte[] bytePathParent = Arrays.copyOfRange(bytePath, 0, bytePath.length - 4);
|
||||
APDUResponse rapdu2 = this.cardBip32GetExtendedKey(bytePathParent);
|
||||
byte[][] extendedkeyParent = this.parser.parseBip32GetExtendedKey(rapdu2);
|
||||
byte[] identifier = Utils.sha256hash160(extendedkeyParent[0]);
|
||||
fingerprint = Arrays.copyOfRange(identifier, 0, 4);
|
||||
childNumber = Arrays.copyOfRange(bytePath, bytePath.length - 4, bytePath.length);
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(78);
|
||||
buffer.putInt(xtype.getHeader());
|
||||
buffer.put((byte) depth);
|
||||
buffer.put(fingerprint);
|
||||
buffer.put(childNumber);
|
||||
buffer.put(extendedkey[1]); // chaincode
|
||||
buffer.put(extendedkey[0]); // pubkey (compressed)
|
||||
byte[] xpubByte = buffer.array();
|
||||
|
||||
return Base58.encodeChecked(xpubByte);
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* SIGNATURES *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardSignTransactionHash(byte keynbr, byte[] txhash, byte[] chalresponse) throws CardException {
|
||||
byte[] data;
|
||||
if(txhash.length != 32) {
|
||||
throw new CardException("Wrong txhash length (should be 32)");
|
||||
}
|
||||
if(chalresponse == null) {
|
||||
data = new byte[32];
|
||||
System.arraycopy(txhash, 0, data, 0, txhash.length);
|
||||
} else if(chalresponse.length == 20) {
|
||||
data = new byte[32 + 2 + 20];
|
||||
int offset = 0;
|
||||
System.arraycopy(txhash, 0, data, offset, txhash.length);
|
||||
offset += 32;
|
||||
data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag
|
||||
data[offset++] = (byte) 0x00;
|
||||
System.arraycopy(chalresponse, 0, data, offset, chalresponse.length);
|
||||
} else {
|
||||
throw new CardException("Wrong challenge-response length (should be 20)");
|
||||
}
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_SIGN_TRANSACTION_HASH, keynbr, 0x00, data);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function signs a given hash with a std or the last extended key
|
||||
* If 2FA is enabled, a HMAC must be provided as an additional security layer. *
|
||||
* ins: 0x7B
|
||||
* p1: key number or 0xFF for the last derived Bip32 extended key
|
||||
* p2: 0x00
|
||||
* data: [hash(32b) | option: 2FA-flag(2b)|hmac(20b)]
|
||||
* return: [sig]
|
||||
*/
|
||||
public APDUResponse cardSignSchnorrHash(byte keynbr, byte[] txhash, byte[] chalresponse) throws CardException {
|
||||
byte[] data;
|
||||
if(txhash.length != 32) {
|
||||
throw new CardException("Wrong txhash length (should be 32)");
|
||||
}
|
||||
if(chalresponse == null) {
|
||||
data = new byte[32];
|
||||
System.arraycopy(txhash, 0, data, 0, txhash.length);
|
||||
} else if(chalresponse.length == 20) {
|
||||
data = new byte[32 + 2 + 20];
|
||||
int offset = 0;
|
||||
System.arraycopy(txhash, 0, data, offset, txhash.length);
|
||||
offset += 32;
|
||||
data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag
|
||||
data[offset++] = (byte) 0x00;
|
||||
System.arraycopy(chalresponse, 0, data, offset, chalresponse.length);
|
||||
} else {
|
||||
throw new CardException("Wrong challenge-response length (should be 20)");
|
||||
}
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, 0x7B, keynbr, 0x00, data);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function tweak the currently available private stored in the Satochip.
|
||||
* Tweaking is based on the 'taproot_tweak_seckey(seckey0, h)' algorithm specification defined here:
|
||||
* https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs
|
||||
* <p>
|
||||
* ins: 0x7C
|
||||
* p1: key number or 0xFF for the last derived Bip32 extended key
|
||||
* p2: 0x00
|
||||
* data: [hash(32b) | option: 2FA-flag(2b)|hmac(20b)]
|
||||
* return: [sig]
|
||||
*/
|
||||
public APDUResponse cardTaprootTweakPrivkey(byte keynbr, byte[] tweak) throws CardException {
|
||||
byte[] data;
|
||||
if(tweak == null) {
|
||||
tweak = new byte[32]; // by default use a 32-byte vector filled with '0x00'
|
||||
}
|
||||
if(tweak.length != 32) {
|
||||
throw new CardException("Wrong tweak length (should be 32)");
|
||||
}
|
||||
data = new byte[33];
|
||||
data[0] = (byte) 32;
|
||||
System.arraycopy(tweak, 0, data, 1, tweak.length);
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, 0x7C, keynbr, 0x00, data);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class SatochipParser {
|
||||
private static final Logger log = LoggerFactory.getLogger(SatochipParser.class);
|
||||
|
||||
public SatochipParser() {
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* PARSER *
|
||||
****************************************/
|
||||
|
||||
public byte[] parseInitiateSecureChannel(APDUResponse rapdu) throws CardException {
|
||||
try {
|
||||
byte[] data = rapdu.getData();
|
||||
|
||||
// data= [coordxSize | coordx | sig1Size | sig1 | sig2Size | sig2]
|
||||
int offset = 0;
|
||||
int coordxSize = 256 * data[offset++] + data[offset++];
|
||||
|
||||
byte[] coordx = new byte[coordxSize];
|
||||
System.arraycopy(data, offset, coordx, 0, coordxSize);
|
||||
offset += coordxSize;
|
||||
|
||||
// msg1 is [coordx_size | coordx]
|
||||
byte[] msg1 = new byte[2 + coordxSize];
|
||||
System.arraycopy(data, 0, msg1, 0, msg1.length);
|
||||
|
||||
int sig1Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig1 = new byte[sig1Size];
|
||||
System.arraycopy(data, offset, sig1, 0, sig1Size);
|
||||
offset += sig1Size;
|
||||
|
||||
// msg2 is [coordxSize | coordx | sig1Size | sig1]
|
||||
byte[] msg2 = new byte[2 + coordxSize + 2 + sig1Size];
|
||||
System.arraycopy(data, 0, msg2, 0, msg2.length);
|
||||
|
||||
int sig2Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig2 = new byte[sig2Size];
|
||||
System.arraycopy(data, offset, sig2, 0, sig2Size);
|
||||
offset += sig2Size;
|
||||
|
||||
return recoverPubkey(msg1, sig1, coordx, false);
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error parsing Satochip response", e);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[][] parseBip32GetExtendedKey(APDUResponse rapdu) throws CardException {
|
||||
try {
|
||||
byte[][] extendedkey = new byte[2][];
|
||||
extendedkey[0] = new byte[33]; // pubkey
|
||||
extendedkey[1] = new byte[32]; // chaincode
|
||||
|
||||
byte[] data = rapdu.getData();
|
||||
//data: [chaincode(32b) | coordx_size(2b) | coordx | sig_size(2b) | sig | sig_size(2b) | sig2]
|
||||
|
||||
int offset = 0;
|
||||
byte[] chaincode = new byte[32];
|
||||
System.arraycopy(data, offset, chaincode, 0, chaincode.length);
|
||||
offset += 32;
|
||||
|
||||
int coordxSize = 256 * (data[offset++] & 0x7f) + data[offset++]; // (data[32] & 0x80) is ignored (optimization flag)
|
||||
byte[] coordx = new byte[coordxSize];
|
||||
System.arraycopy(data, offset, coordx, 0, coordxSize);
|
||||
offset += coordxSize;
|
||||
|
||||
// msg1 is [chaincode | coordx_size | coordx]
|
||||
byte[] msg1 = new byte[32 + 2 + coordxSize];
|
||||
System.arraycopy(data, 0, msg1, 0, msg1.length);
|
||||
|
||||
int sig1Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig1 = new byte[sig1Size];
|
||||
System.arraycopy(data, offset, sig1, 0, sig1Size);
|
||||
offset += sig1Size;
|
||||
|
||||
// msg2 is [chaincode | coordxSize | coordx | sig1Size | sig1]
|
||||
byte[] msg2 = new byte[32 + 2 + coordxSize + 2 + sig1Size];
|
||||
System.arraycopy(data, 0, msg2, 0, msg2.length);
|
||||
|
||||
int sig2Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig2 = new byte[sig2Size];
|
||||
System.arraycopy(data, offset, sig2, 0, sig2Size);
|
||||
offset += sig2Size;
|
||||
|
||||
byte[] pubkey = recoverPubkey(msg1, sig1, coordx, true); // true: compressed (33 bytes)
|
||||
|
||||
// todo: recover from si2
|
||||
System.arraycopy(pubkey, 0, extendedkey[0], 0, pubkey.length);
|
||||
System.arraycopy(chaincode, 0, extendedkey[1], 0, chaincode.length);
|
||||
return extendedkey;
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error parsing Satochip extended key", e);
|
||||
}
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* recovery methods *
|
||||
****************************************/
|
||||
|
||||
public byte[] recoverPubkey(byte[] msg, byte[] dersig, byte[] coordx, Boolean compressed) throws CardException {
|
||||
// convert msg to hash
|
||||
//byte[] hash = Sha256Hash.hash(msg);
|
||||
ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(dersig);
|
||||
|
||||
byte recId = -1;
|
||||
ECKey k = null;
|
||||
for(byte i = 0; i < 4; i++) {
|
||||
k = ECKey.recoverFromSignature(i, ecdsaSig, Sha256Hash.of(msg), compressed);
|
||||
if(k != null && Arrays.equals(k.getPubKeyXCoord(), coordx)) {
|
||||
recId = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(recId == -1) {
|
||||
throw new CardException("Could not construct a recoverable key. This should never happen.");
|
||||
}
|
||||
|
||||
return k.getPubKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.AESKeyCrypter;
|
||||
|
||||
import com.sparrowwallet.drongo.bip47.SecretPoint;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.Key;
|
||||
import com.sparrowwallet.drongo.crypto.EncryptedData;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.smartcardio.CardException;
|
||||
import java.security.SecureRandom;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Handles a SecureChannel session with the card.
|
||||
*/
|
||||
public class SecureChannelSession {
|
||||
private static final Logger log = LoggerFactory.getLogger(SecureChannelSession.class);
|
||||
|
||||
public static final int SC_SECRET_LENGTH = 16;
|
||||
public static final int SC_BLOCK_SIZE = 16;
|
||||
public static final int IV_SIZE = 16;
|
||||
public static final int MAC_SIZE = 20;
|
||||
|
||||
// secure channel constants
|
||||
private final static byte INS_INIT_SECURE_CHANNEL = (byte) 0x81;
|
||||
private final static byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82;
|
||||
private final static short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20;
|
||||
private final static short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21;
|
||||
private final static short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22;
|
||||
private final static short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23;
|
||||
|
||||
private boolean initialized_secure_channel = false;
|
||||
|
||||
// secure channel keys
|
||||
private byte[] secret;
|
||||
private byte[] iv;
|
||||
private int ivCounter;
|
||||
byte[] derived_key;
|
||||
byte[] mac_key;
|
||||
|
||||
// for ECDH
|
||||
private SecretPoint secretPoint;
|
||||
private final ECKey eckey;
|
||||
|
||||
// for session encryption
|
||||
private final SecureRandom random;
|
||||
private final AESKeyCrypter aesCipher;
|
||||
|
||||
/**
|
||||
* Constructs a SecureChannel session on the client.
|
||||
*/
|
||||
public SecureChannelSession() {
|
||||
random = new SecureRandom();
|
||||
|
||||
// generate keypair
|
||||
eckey = new ECKey();
|
||||
aesCipher = new AESKeyCrypter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pairing secret. This should be called before each session. The public key of the card is used as input
|
||||
* for the EC-DH algorithm. The output is stored as the secret.
|
||||
*
|
||||
* @param pubkeyData the public key returned by the applet as response to the SELECT command
|
||||
*/
|
||||
public void initiateSecureChannel(byte[] pubkeyData) { //TODO: check keyData format
|
||||
try {
|
||||
byte[] privkeyData = this.eckey.getPrivKeyBytes();
|
||||
secretPoint = new SecretPoint(privkeyData, pubkeyData);
|
||||
secret = secretPoint.ECDHSecretAsBytes();
|
||||
//log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() secret: " + Utils.bytesToHex(secret));
|
||||
|
||||
// derive session encryption key
|
||||
byte[] msg_key = "sc_key".getBytes();
|
||||
byte[] derived_key_2Ob = getHmacSha1Hash(secret, msg_key);
|
||||
derived_key = new byte[16];
|
||||
System.arraycopy(derived_key_2Ob, 0, derived_key, 0, 16);
|
||||
//log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() derived_key: " + Utils.bytesToHex(derived_key));
|
||||
// derive session mac key
|
||||
byte[] msg_mac = "sc_mac".getBytes();
|
||||
mac_key = getHmacSha1Hash(secret, msg_mac);
|
||||
//log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() mac_key: " + Utils.bytesToHex(mac_key));
|
||||
|
||||
ivCounter = 1;
|
||||
initialized_secure_channel = true;
|
||||
} catch(Exception e) {
|
||||
log.error("Error initiating secure channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public APDUCommand encryptSecureChannel(APDUCommand plainApdu) throws CardException {
|
||||
try {
|
||||
byte[] plainBytes = plainApdu.serialize();
|
||||
|
||||
// set iv
|
||||
iv = new byte[SC_BLOCK_SIZE];
|
||||
random.nextBytes(iv);
|
||||
ByteBuffer bb = ByteBuffer.allocate(4);
|
||||
bb.putInt(ivCounter); // big endian
|
||||
byte[] ivCounterBytes = bb.array();
|
||||
System.arraycopy(ivCounterBytes, 0, iv, 12, 4);
|
||||
ivCounter += 2;
|
||||
|
||||
// encrypt data
|
||||
Key aesKey = new Key(derived_key, null, null);
|
||||
byte[] encrypted = aesCipher.encrypt(plainBytes, iv, aesKey).getEncryptedBytes();
|
||||
|
||||
// mac
|
||||
int offset = 0;
|
||||
byte[] data_to_mac = new byte[IV_SIZE + 2 + encrypted.length];
|
||||
System.arraycopy(iv, offset, data_to_mac, offset, IV_SIZE);
|
||||
offset += IV_SIZE;
|
||||
data_to_mac[offset++] = (byte) (encrypted.length >> 8);
|
||||
data_to_mac[offset++] = (byte) (encrypted.length % 256);
|
||||
System.arraycopy(encrypted, 0, data_to_mac, offset, encrypted.length);
|
||||
// log.trace("SATOCHIP data_to_mac: "+ SatochipParser.toHexString(data_to_mac));
|
||||
byte[] mac = getHmacSha1Hash(mac_key, data_to_mac);
|
||||
|
||||
// copy all data to new data buffer
|
||||
offset = 0;
|
||||
byte[] data = new byte[IV_SIZE + 2 + encrypted.length + 2 + MAC_SIZE];
|
||||
System.arraycopy(iv, offset, data, offset, IV_SIZE);
|
||||
offset += IV_SIZE;
|
||||
data[offset++] = (byte) (encrypted.length >> 8);
|
||||
data[offset++] = (byte) (encrypted.length % 256);
|
||||
System.arraycopy(encrypted, 0, data, offset, encrypted.length);
|
||||
offset += encrypted.length;
|
||||
data[offset++] = (byte) (mac.length >> 8);
|
||||
data[offset++] = (byte) (mac.length % 256);
|
||||
System.arraycopy(mac, 0, data, offset, mac.length);
|
||||
|
||||
// convert to C-APDU
|
||||
return new APDUCommand(0xB0, INS_PROCESS_SECURE_CHANNEL, 0x00, 0x00, data);
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error encrypting secure channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public APDUResponse decryptSecureChannel(APDUResponse encryptedApdu) throws CardException {
|
||||
try {
|
||||
byte[] encryptedBytes = encryptedApdu.getData();
|
||||
if(encryptedBytes.length == 0) {
|
||||
return encryptedApdu; // no decryption needed
|
||||
} else if(encryptedBytes.length < 40) {
|
||||
// has at least (IV_SIZE + 2 + 2 + 20)
|
||||
throw new RuntimeException("Encrypted response has wrong length: " + encryptedBytes.length);
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
byte[] iv = new byte[IV_SIZE];
|
||||
System.arraycopy(encryptedBytes, offset, iv, 0, IV_SIZE);
|
||||
offset += IV_SIZE;
|
||||
int ciphertext_size = ((encryptedBytes[offset++] & 0xff) << 8) + (encryptedBytes[offset++] & 0xff);
|
||||
if((encryptedBytes.length - offset) != ciphertext_size) {
|
||||
throw new RuntimeException("Encrypted response has wrong length ciphertext_size: " + ciphertext_size);
|
||||
}
|
||||
byte[] ciphertext = new byte[ciphertext_size];
|
||||
System.arraycopy(encryptedBytes, offset, ciphertext, 0, ciphertext.length);
|
||||
|
||||
// decrypt data
|
||||
Key aesKey = new Key(derived_key, null, null);
|
||||
EncryptedData encryptedData = new EncryptedData(iv, ciphertext, null, null);
|
||||
byte[] decrypted = aesCipher.decrypt(encryptedData, aesKey);
|
||||
|
||||
return new APDUResponse(decrypted, (byte) 0x90, (byte) 0x00);
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error decrypting secure channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean initializedSecureChannel() {
|
||||
return initialized_secure_channel;
|
||||
}
|
||||
|
||||
public byte[] getPublicKey() {
|
||||
return eckey.getPubKey(false);
|
||||
}
|
||||
|
||||
public void resetSecureChannel() {
|
||||
initialized_secure_channel = false;
|
||||
}
|
||||
|
||||
public static byte[] getHmacSha1Hash(byte[] key, byte[] data) {
|
||||
try {
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA1");
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
mac.init(secretKeySpec);
|
||||
return mac.doFinal(data);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException("Error computing HmacSHA1", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
|
||||
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
|
||||
import com.sparrowwallet.sparrow.io.satochip.Satochip;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Accordion;
|
||||
import org.slf4j.Logger;
|
||||
@@ -15,8 +16,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);
|
||||
|
||||
@@ -26,9 +25,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
public void initializeView() {
|
||||
List<KeystoreFileImport> fileImporters = Collections.emptyList();
|
||||
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());
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux());
|
||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
||||
fileImporters = List.of(new Bip129(), 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(), new Krux());
|
||||
}
|
||||
|
||||
for(KeystoreFileImport importer : fileImporters) {
|
||||
@@ -40,10 +39,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(), new Satochip());
|
||||
for(KeystoreCardImport importer : cardImporters) {
|
||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user