Compare commits

...
103 Commits
Author SHA1 Message Date
Craig Raw 77c305f90b tweak fix on recent blocks view 2025-05-21 10:29:01 +02:00
Craig Raw 276f8b4148 fix npe on null fee returned from server 2025-05-21 10:12:38 +02:00
Craig Raw b3c92617c9 minor fixes on recent blocks view 2025-05-21 10:05:58 +02:00
Craig Raw 58635801fc add icons for external sources in settings and recent blocks view 2025-05-21 09:55:22 +02:00
Craig Raw 8c32bb3903 followup 2025-05-20 19:45:43 +02:00
Craig Raw 55a2c86a83 upgrade tor resource to fix uuid issue on recent macos platforms 2025-05-20 19:40:16 +02:00
Craig Raw 345e018eb9 repackage .deb installs to use older gzip instead of zstd compression 2025-05-20 13:41:12 +02:00
Craig Raw 45d2dee764 remove display of median fee rate where fee rates source is set to server 2025-05-20 12:04:06 +02:00
Craig Raw 250bc84060 bump to v2.2.1 2025-05-20 10:58:21 +02:00
Craig Raw c3dba8ede6 bump to v2.2.0 2025-05-19 11:37:52 +02:00
Craig Raw db478f8da6 further followup tweaks 2025-05-19 09:11:12 +02:00
Craig Raw 4ab9a9f681 followup tweaks 2025-05-16 18:49:58 +02:00
Craig Raw c078aea3b4 show total in transaction diagram when constructing multiple payment transactions 2025-05-16 17:00:40 +02:00
Craig Raw af4a283b3f increase trezor device libusb timeout 2025-05-16 10:02:03 +02:00
Craig Raw 892885c0b1 make wallet summary table grow horizontally with dialog sizing 2025-05-15 15:01:09 +02:00
Craig Raw d4a1441d65 more recent blocks tweaks 2025-05-15 12:42:53 +02:00
Craig Raw 1605cd2619 followup 2025-05-15 12:10:54 +02:00
Craig Raw b4d34aacc5 tweak block cube median fee font styling 2025-05-15 12:03:24 +02:00
Craig Raw 1a4f0113c7 followup 2 2025-05-15 10:49:09 +02:00
Craig Raw 055e3ac496 followup 2025-05-15 10:00:03 +02:00
Craig Raw d0da85171c rename sparrow package to sparrowwallet and sparrowserver on linux 2025-05-15 09:28:42 +02:00
Craig Raw af4c68a09c update tor resource library and switch to resource-filterjar plugin 2025-05-14 11:38:17 +02:00
Craig Raw b1ab157ee3 cormorant: add block stats rpc call, and prefer for block summaries 2025-05-14 10:52:21 +02:00
Craig Raw 94b27ba7e8 add recent blocks view 2025-05-14 08:19:21 +02:00
Craig Raw e697313259 add accessible text to improve screen reader navigation 2025-05-08 10:10:50 +02:00
Craig Raw 1b0e5e9726 revert rpm package name change 2025-05-07 16:05:17 +02:00
Craig Raw df0c4310ca optimize and reduce electrum server rpc calls #3 2025-05-07 16:03:02 +02:00
Craig Raw 474f3a4e91 add custom filterjar plugin to filter out unneeded native binaries per platform 2025-05-07 10:37:56 +02:00
Craig Raw c6e42d8fe2 rename rpm package name from sparrow to sparrowwallet to avoid conflicts 2025-05-05 15:11:04 +02:00
Craig Raw 3698ca8e85 reduce tooltip show delay to 200ms 2025-05-05 14:53:24 +02:00
Craig Raw 53c5a8d2df update kmp-tor to 2.2.1 and remove runtime module config 2025-05-05 14:51:15 +02:00
Craig Raw 3d85491e6b add block summary service 2025-05-05 14:43:42 +02:00
Craig Raw c77f52f7f6 optimize and reduce electrum server rpc calls #2 2025-04-29 12:49:58 +02:00
Craig Raw e3138f3392 optimize and reduce electrum server rpc calls 2025-04-28 14:39:30 +02:00
Craig Raw 7a4015fdb5 convert images to theme aware svg for all wallet models and dialogs 2025-04-25 17:37:32 +02:00
Craig Raw 94d15c09e6 cormorant: avoid calling listwalletdir rpc on initialization due to extremely slow response on windows 2025-04-18 09:43:57 +02:00
Craig Raw 71ac72e9f6 upgrade internal tor to 0.4.8.15 2025-04-17 14:17:26 +02:00
Craig Raw be8b56e355 fix inclusion of fees on wallet label exports 2025-04-14 16:27:12 +02:00
Craig Raw af8505c0eb support send and display of pay to anchor outputs 2025-04-14 15:50:10 +02:00
Craig Raw 5edabf2e14 minor fixes to private key sweep on bitcoin core 2025-04-11 13:43:19 +02:00
Craig Raw c73ebdc8a2 show address where available on input and output tooltips in transaction tab tree 2025-04-10 17:01:18 +02:00
Craig Raw c9d7b8ef9a dynamically truncate input and output labels in the tree on a transaction tab, and add tooltips if necessary 2025-04-10 15:42:04 +02:00
Craig Raw b3a6340c45 simplify camera pixel format prioritisation 2025-04-08 14:50:38 +02:00
Craig Raw 0975d12155 sort camera pixel formats on linux only 2025-04-08 13:48:59 +02:00
Craig Raw e31aa7fc80 avoid server address resolution for public servers, and assume non local for failures where a proxy is configured 2025-04-06 20:55:35 +02:00
Craig Raw b777c8c64d fix for building on headless with earlier javafx 2025-04-03 16:11:23 +02:00
Craig Raw 4176f76ffc update to build on ubuntu 22.04 2025-04-03 15:59:07 +02:00
Craig Raw 64dac72f4f show transaction diagram fee percentage as less than 0.01% rather than 0.00% 2025-04-03 15:35:51 +02:00
Craig Raw e29559f59c fix issue parsing remote coldcard xpub encoded on a different network 2025-04-03 15:18:42 +02:00
Craig Raw b1223ef064 reset preferred table column widths on adjustment 2025-04-03 14:41:43 +02:00
Craig Raw 6f0a30cc25 prefer yuyv to mjpg capture format 2025-04-03 14:15:19 +02:00
Craig Raw 2fa8e5fd70 improve tabs and transaction diagram tooltips with long labels 2025-04-03 14:10:02 +02:00
Craig Raw a8f7ce9e34 add tooltip for truncated labels in table cells 2025-04-02 10:32:11 +02:00
Craig Raw c946ef7479 upgrade bouncy castle, pgpainless and logback 2025-04-01 14:59:55 +02:00
Craig Raw 7fa13901d4 fix typo 2025-03-20 12:07:07 +02:00
Craig Raw 8a88488a42 update openpnp-capture to v0.0.28-5, fix typo 2025-03-20 11:48:22 +02:00
Craig Raw 25a3f5539d sort retrieved capture formats in order of supported, unknown and unsupported pixel formats 2025-03-20 10:47:07 +02:00
Craig Raw 520c5f2cfa revert initialization change, configure openpnp debug logging 2025-03-14 11:27:25 +02:00
Craig Raw d8877a259c initialize capture library in service thread, fix sigsegv fault 2025-03-14 09:40:30 +02:00
Craig Raw 7de63b2b5f suppress unneeded warning on zoom detection 2025-03-13 17:55:49 +02:00
Craig Raw f1c4b8aa69 support camera zoom during capture with mouse scroll 2025-03-13 17:43:55 +02:00
Craig Raw 6f6d61fb75 minor webcam cross platform fixes 2025-03-13 16:54:47 +02:00
Craig Raw 2c4de99fad improve capture display efficiency, fix resizing bug and refactor 2025-03-13 13:52:01 +02:00
Craig Raw 3e197eb310 support capturing using additional webcam resolutions of fhd and uhd4k 2025-03-13 08:30:53 +02:00
Craig Raw bd5af560ff fix non-zero account script type detection when signing a message on trezor devices 2025-03-12 08:52:08 +02:00
Craig Raw 3b9551a8c6 replace sarxos/openimaj library with openpnp-capture library 2025-03-11 16:21:27 +02:00
Craig Raw 289a4453a4 fix issue with random ordering of keystore origins on labels import 2025-03-10 11:38:26 +02:00
Craig Raw 27e21c890f refactor ioutils to drongo 2025-03-04 15:08:26 +02:00
Craig Raw 4239a56bc1 show warning when importing a wallet with a derivation path matching another script type 2025-03-04 11:48:03 +02:00
Craig Raw 5c9de07d48 prefer verifying dropped file over default file where file is not in manifest 2025-03-03 14:17:53 +02:00
Craig Raw 9a8a25344a bump to v2.1.4 2025-02-27 12:51:30 +02:00
Craig Raw be86b4feaa fix access issue with macos show/hide windowing commands 2025-02-27 11:08:01 +02:00
Craig Raw 37763e9557 verify dropped release file instead of first platform specific release file found 2025-02-27 11:03:53 +02:00
Craig Raw 80c4f4f5f6 make wallet labels export and import scannable 2025-02-26 12:01:05 +02:00
Craig Raw 6c3fe93d1e exclude heights of confirming txes from wallet labels export 2025-02-26 11:44:27 +02:00
Craig Raw 76eff2de48 merge wallet labels optional fields draft implementation 2025-02-26 10:47:17 +02:00
Craig Raw 07a6818823 use default key origin information when importing a descriptor without key origin info 2025-02-25 10:55:37 +02:00
Craig Raw 2253a1bb97 add support for onekey pro and classic 1s hardware wallets 2025-02-20 17:04:56 +02:00
Craig Raw 36ee8add08 add bip47 notification transaction test 2025-02-19 11:31:32 +02:00
Craig Raw 883e75c0df add copy payment code to transaction diagram outputs context menu 2025-02-19 08:45:52 +02:00
Craig Raw cc908b09c7 upgrade to libusb 1.0.27 on all platforms 2025-02-18 15:30:34 +02:00
Craig Raw ce963ed5b6 add specific handling for invalid windows device drivers on trezor devices 2025-02-18 13:32:02 +02:00
Craig Raw 951e33dc06 fix handling of high account numbers on ledger devices 2025-02-18 12:45:50 +02:00
Craig Raw 6a6a6b1cca additionally check for trezor model against internal name, improve exception handling on no match 2025-02-16 08:43:45 +02:00
Craig Raw 8953d404fa fix stripping leading zeros from master fingerprint on importing some trezor models 2025-02-14 18:42:44 +02:00
Thauan AmorimandGitHub b366177782 add show transaction as qr button to signed transaction tab when offline
* [feature/1630] Add QR code button on signed transaction screen

* [feature/1630] Button positioning improvements

* [feature/1630] Added owner to qrDisplayDialog
2025-02-13 09:05:11 +02:00
Craig Raw d0c827c2c7 fix various minor issues around multisig keystore labelling and export button visibility 2025-02-13 08:43:55 +02:00
Craig Raw 5c29bf51b7 handle scanning and pasting server urls in the electrum format 2025-02-11 14:03:43 +02:00
Craig Raw d426703dcc fix account discovery on bitbox02 2025-02-11 13:18:21 +02:00
Craig Raw 78f0721168 bump to v2.1.3 2025-02-08 16:12:38 +02:00
Craig Raw 20d3f07059 draft implementation of optional bip329 fields 2025-02-08 11:43:59 +02:00
Craig Raw 1140a678ad followup 2025-02-08 11:38:41 +02:00
Craig Raw 6e8d44bc8c set script type import button as default after bip39 wallet discovery returns empty 2025-02-08 09:44:21 +02:00
Craig Raw ad3b384feb fix loading hex txn files with trailing whitespace 2025-02-08 09:30:21 +02:00
Craig Raw f38350b38d prefill derivation to default path for script type on watch only keystores 2025-02-07 16:22:20 +02:00
Craig Raw 62060c9839 fix unsigned byte to int conversion for ledger get_more_elements command 2025-02-07 15:56:35 +02:00
Craig Raw 8975f6f666 followup 2025-02-07 14:57:21 +02:00
Craig Raw c7351cd191 indicate historical rates support in exchange source drop-down 2025-02-07 14:52:17 +02:00
Craig Raw 62b1dc3900 support coldcard p2tr address display and show correct address for script type on message sign 2025-02-06 16:12:49 +02:00
Craig Raw f37ff47850 fix invalid claimed length error on transaction file load 2025-02-06 15:36:37 +02:00
Craig Raw cfaa1f6c6e add local network usage description for macos 15 2025-02-06 10:08:28 +02:00
Craig Raw 91c94b94eb upgrade flyway to v9.22.3 2025-02-06 09:37:51 +02:00
Craig Raw a5eb7da067 bump to v2.1.2 2025-02-05 09:21:21 +02:00
404 changed files with 5924 additions and 1947 deletions
+7 -4
View File
@@ -10,7 +10,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-2022, ubuntu-20.04, ubuntu-22.04-arm, macos-13, macos-14]
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-13, macos-14]
steps:
- uses: actions/checkout@v4
with:
@@ -30,7 +30,10 @@ jobs:
- name: Package tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
- name: Repackage deb distribution
if: ${{ runner.os == 'Linux' }}
run: ./repackage.sh
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: Sparrow Build - ${{ runner.os }} ${{ runner.arch }}
@@ -43,9 +46,9 @@ jobs:
- name: Package headless tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Rename Headless Artifacts
- name: Repackage headless deb distribution
if: ${{ runner.os == 'Linux' }}
run: for f in build/jpackage/sparrow*; do mv -v "$f" "${f/sparrow/sparrow-server}"; done;
run: ./repackage.sh
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v4
+58 -176
View File
@@ -3,39 +3,24 @@ plugins {
id 'org-openjfx-javafxplugin'
id 'org.beryx.jlink' version '3.1.1'
id 'org.gradlex.extra-java-module-info' version '1.9'
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.16.3'
}
def sparrowVersion = '2.1.1'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
osName = "osx"
}
def targetName = ""
def osArch = "x64"
def releaseArch = "x86_64"
if(System.getProperty("os.arch") == "aarch64") {
osArch = "aarch64"
releaseArch = "aarch64"
targetName = "-" + osArch
}
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}"
group 'com.sparrowwallet'
version '2.2.1'
repositories {
mavenCentral()
@@ -72,7 +57,7 @@ dependencies {
implementation('org.jdbi:jdbi3-sqlobject:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:9.1.3')
implementation('org.flywaydb:flyway-core:9.22.3')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') {
@@ -89,31 +74,10 @@ dependencies {
implementation('com.fasterxml.jackson.core:jackson-databind:2.17.2')
implementation('com.sparrowwallet:hummingbird:1.7.4')
implementation('co.nstant.in:cbor:0.9')
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
}
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("io.matthewnelson.kotlin-components:kmp-tor:${vTor}-${vKmpTor}") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
if(kmpOs == "linux" && kmpArch == "arm64") {
implementation("com.sparrowwallet.kmp-tor-binary:kmp-tor-binary-${kmpOs}${kmpArch}-jvm:${vTor}") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
} else {
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-${kmpOs}${kmpArch}:${vTor}") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
}
implementation("io.matthewnelson.kotlin-components:kmp-tor-binary-extract:${vTor}") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
implementation("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager:${vKmpTor}") {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.1') {
implementation('org.openpnp:openpnp-capture-java:0.0.28-5')
implementation("io.matthewnelson.kmp-tor:runtime:2.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.3")
implementation('org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.10.1') {
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-common'
}
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
@@ -201,7 +165,7 @@ application {
"--add-reads=org.flywaydb.core=java.desktop"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
if(headless) {
@@ -256,6 +220,7 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.pg",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.provider",
"--add-reads=com.sparrowwallet.merged.module=kotlin.stdlib",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
"--add-reads=org.flywaydb.core=java.desktop"]
@@ -263,7 +228,7 @@ jlink {
jvmArgs += ["-Djavax.accessibility.assistive_technologies", "-Djavax.accessibility.screen_magnifier_present=false"]
}
if(os.macOsX) {
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
if(headless) {
jvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
@@ -273,7 +238,7 @@ jlink {
jpackage {
imageName = "Sparrow"
installerName = "Sparrow"
appVersion = "${sparrowVersion}"
appVersion = "${version}"
skipInstaller = os.macOsX || properties.skipInstallers
imageOptions = []
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/asc.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.properties', '--license-file', 'LICENSE']
@@ -284,11 +249,13 @@ jlink {
}
if(os.linux) {
if(headless) {
installerOptions = ['--license-file', 'LICENSE', '--resource-dir', "src/main/deploy/package/linux-headless/${osArch}"]
installerName = "sparrowserver"
installerOptions = ['--license-file', 'LICENSE']
} else {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow']
installerName = "sparrowwallet"
installerOptions += ['--linux-shortcut', '--linux-menu-group', 'Sparrow']
}
installerOptions += ['--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
installerOptions += ['--resource-dir', layout.buildDirectory.dir('deploy/package').get().asFile.toString(), '--linux-app-category', 'utils', '--linux-app-release', '1', '--linux-rpm-license-type', 'ASL 2.0', '--linux-deb-maintainer', 'mail@sparrowwallet.com']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
}
if(os.macOsX) {
@@ -306,6 +273,7 @@ jlink {
if(os.linux) {
tasks.jlink.finalizedBy('addUserWritePermission', 'copyUdevRules')
tasks.jpackageImage.finalizedBy('prepareResourceDir')
} else {
tasks.jlink.finalizedBy('addUserWritePermission')
}
@@ -324,12 +292,42 @@ tasks.register('copyUdevRules', Copy) {
include('*')
}
tasks.register('prepareResourceDir', Copy) {
from("src/main/deploy/package/linux${headless ? '-headless' : ''}")
into(layout.buildDirectory.dir('deploy/package'))
include('*')
eachFile { file ->
if(file.name.equals('control') || file.name.endsWith('.spec')) {
filter { line ->
if(line.contains('${size}')) {
line = line.replace('${size}', getDirectorySize(layout.buildDirectory.dir('jpackage/Sparrow').get().asFile))
}
return line.replace('${version}', "${version}").replace('${arch}', osArch == 'aarch64' ? 'arm64' : 'amd64')
}
}
}
}
static def getDirectorySize(File directory) {
long size = 0
if(directory.isFile()) {
size = directory.length()
} else if(directory.isDirectory()) {
directory.eachFileRecurse { file ->
if(file.isFile()) {
size += file.length()
}
}
}
return Long.toString(size/1024 as long)
}
tasks.register('removeGroupWritePermission', Exec) {
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
}
tasks.register('packageZipDistribution', Zip) {
archiveFileName = "Sparrow-${sparrowVersion}.zip"
archiveFileName = "Sparrow-${version}.zip"
destinationDirectory = file("$buildDir/jpackage")
preserveFileTimestamps = os.macOsX
from("$buildDir/jpackage/") {
@@ -340,7 +338,7 @@ tasks.register('packageZipDistribution', Zip) {
tasks.register('packageTarDistribution', Tar) {
dependsOn removeGroupWritePermission
archiveFileName = "sparrow-${sparrowVersion}-${releaseArch}.tar.gz"
archiveFileName = "sparrow${headless ? 'server': 'wallet'}-${version}-${releaseArch}.tar.gz"
destinationDirectory = file("$buildDir/jpackage")
compression = Compression.GZIP
from("$buildDir/jpackage/") {
@@ -376,18 +374,11 @@ extraJavaModuleInfo {
requires('org.slf4j')
requires('com.fasterxml.jackson.databind')
}
module("com.nativelibs4java:bridj${targetName}", 'com.nativelibs4java.bridj') {
exports('org.bridj')
exports('org.bridj.cpp')
requires('java.logging')
}
module("com.github.sarxos:webcam-capture${targetName}", 'com.github.sarxos.webcam.capture') {
exports('com.github.sarxos.webcam')
exports('com.github.sarxos.webcam.ds.buildin')
exports('com.github.sarxos.webcam.ds.buildin.natives')
module('org.openpnp:openpnp-capture-java', 'openpnp.capture.java') {
exports('org.openpnp.capture')
exports('org.openpnp.capture.library')
requires('java.desktop')
requires('com.nativelibs4java.bridj')
requires('org.slf4j')
requires('com.sun.jna')
}
module('de.codecentric.centerdevice:centerdevice-nsmenufx', 'centerdevice.nsmenufx') {
exports('de.codecentric.centerdevice')
@@ -485,119 +476,6 @@ extraJavaModuleInfo {
exports('net.coobird.thumbnailator')
requires('java.desktop')
}
module("io.matthewnelson.kotlin-components:kmp-tor-jvm", 'kmp.tor.jvm') {
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('kotlinx.coroutines.core')
requires('java.management')
}
if(kmpOs == "linux" && kmpArch == "arm64") {
module("com.sparrowwallet.kmp-tor-binary:kmp-tor-binary-${kmpOs}${kmpArch}-jvm", "kmp.tor.binary.${kmpOs}${kmpArch}") {
exports("io.matthewnelson.kmp.tor.resource.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
} else {
module("io.matthewnelson.kotlin-components:kmp-tor-binary-${kmpOs}${kmpArch}-jvm", "kmp.tor.binary.${kmpOs}${kmpArch}") {
exports("io.matthewnelson.kmp.tor.binary.${kmpOs}.${kmpArch}")
exports("kmptor.${kmpOs}.${kmpArch}")
}
}
module("io.matthewnelson.kotlin-components:kmp-tor-binary-extract-jvm", 'kmp.tor.binary.extract.jvm') {
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("io.matthewnelson.kotlin-components:kmp-tor-manager-jvm", 'kmp.tor.manager.jvm') {
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("io.matthewnelson.kotlin-components:kmp-tor-manager-common-jvm", 'kmp.tor.manager.common.jvm') {
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("io.matthewnelson.kotlin-components:kmp-tor-controller-common-jvm", 'kmp.tor.controller.common.jvm') {
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("io.matthewnelson.kotlin-components:kmp-tor-common-jvm", 'kmp.tor.common.jvm') {
exports('io.matthewnelson.kmp.tor.common.address')
requires('parcelize.jvm')
requires('kotlin.stdlib')
}
module("io.matthewnelson.kotlin-components:kmp-tor-controller-jvm", 'kmp.tor.controller.jvm') {
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("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-common-jvm", 'kmp.tor.ext.callback.common.jvm') {
exports('io.matthewnelson.kmp.tor.ext.callback.common')
}
module("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager-jvm", 'kmp.tor.ext.callback.manager.jvm') {
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("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-manager-common-jvm", 'kmp.tor.ext.callback.manager.common.jvm') {
exports('io.matthewnelson.kmp.tor.ext.callback.manager.common')
requires('kmp.tor.ext.callback.controller.common.jvm')
}
module("io.matthewnelson.kotlin-components:kmp-tor-ext-callback-controller-common-jvm", 'kmp.tor.ext.callback.controller.common.jvm') {
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control')
exports('io.matthewnelson.kmp.tor.ext.callback.controller.common.control.usecase')
}
module("io.matthewnelson.kotlin-components:kmp-tor-binary-geoip-jvm", 'kmp.tor.binary.geoip.jvm') {
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('org.jcommander:jcommander', 'org.jcommander') {
exports('com.beust.jcommander')
}
@@ -612,4 +490,8 @@ extraJavaModuleInfo {
module('com.jcraft:jzlib', 'com.jcraft.jzlib') {
exports('com.jcraft.jzlib')
}
}
kmpTorResourceFilterJar {
keepTorCompilation("current","current")
}
+1 -1
View File
@@ -83,7 +83,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="2.0.0"
GIT_TAG="2.2.0"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: ca758e1288...abb598d3b0
+1 -1
Submodule lark updated: b80cbbbc57...5facb25ede
Executable
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
set -e # Exit on any error
# Define paths
BUILD_DIR="build"
JPACKAGE_DIR="$BUILD_DIR/jpackage"
TEMP_DIR="$BUILD_DIR/repackage"
# Find the .deb file in build/jpackage (assuming there is only one)
DEB_FILE=$(find "$JPACKAGE_DIR" -type f -name "*.deb" -print -quit)
# Check if a .deb file was found
if [ -z "$DEB_FILE" ]; then
echo "Error: No .deb file found in $JPACKAGE_DIR"
exit 1
fi
# Extract the filename from the path for later use
DEB_FILENAME=$(basename "$DEB_FILE")
echo "Found .deb file: $DEB_FILENAME"
# Create a temp directory inside build to avoid file conflicts
mkdir -p "$TEMP_DIR"
cd "$TEMP_DIR"
# Extract the .deb file contents
ar x "../../$DEB_FILE"
# Decompress zst files to tar
unzstd control.tar.zst
unzstd data.tar.zst
# Compress tar files to xz
xz -c control.tar > control.tar.xz
xz -c data.tar > data.tar.xz
# Remove the original .deb file
rm "../../$DEB_FILE"
# Create the new .deb file with xz compression in the original location
ar cr "../../$DEB_FILE" debian-binary control.tar.xz data.tar.xz
# Clean up temp files
cd ../..
rm -rf "$TEMP_DIR"
echo "Repackaging complete: $DEB_FILENAME"
@@ -1,9 +0,0 @@
Package: sparrow
Version: 2.1.1-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: arm64
Provides: sparrow
Description: Sparrow
Depends: libc6, zlib1g
@@ -0,0 +1,12 @@
Package: sparrowserver
Version: ${version}-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: ${arch}
Conflicts: sparrow (<= 2.1.4)
Replaces: sparrow (<= 2.1.4)
Provides: sparrowserver
Description: Sparrow Server
Depends: libc6, zlib1g
Installed-Size: ${size}
+85
View File
@@ -0,0 +1,85 @@
Summary: Sparrow Server
Name: sparrowserver
Version: ${version}
Release: 1
License: ASL 2.0
Vendor: Unknown
%if "x" != "x"
URL: https://sparrowwallet.com
%endif
%if "x/opt" != "x"
Prefix: /opt
%endif
Provides: sparrowserver
Obsoletes: sparrow <= 2.1.4
%if "xutils" != "x"
Group: utils
%endif
Autoprov: 0
Autoreq: 0
#comment line below to enable effective jar compression
#it could easily get your package size from 40 to 15Mb but
#build time will substantially increase and it may require unpack200/system java to install
%define __jar_repack %{nil}
# on RHEL we got unwanted improved debugging enhancements
%define _build_id_links none
%define package_filelist %{_builddir}/%{name}.files
%define app_filelist %{_builddir}/%{name}.app.files
%define filesystem_filelist %{_builddir}/%{name}.filesystem.files
%define default_filesystem / /opt /usr /usr/bin /usr/lib /usr/local /usr/local/bin /usr/local/lib
%description
Sparrow Server
%global __os_install_post %{nil}
%prep
%build
%install
rm -rf %{buildroot}
install -d -m 755 %{buildroot}/opt/sparrowserver
cp -r %{_sourcedir}/opt/sparrowserver/* %{buildroot}/opt/sparrowserver
if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then
install -d -m 755 %{buildroot}/lib/systemd/system
cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system
fi
%if "x%{_rpmdir}/../../LICENSE" != "x"
%define license_install_file %{_defaultlicensedir}/%{name}-%{version}/%{basename:%{_rpmdir}/../../LICENSE}
install -d -m 755 "%{buildroot}%{dirname:%{license_install_file}}"
install -m 644 "%{_rpmdir}/../../LICENSE" "%{buildroot}%{license_install_file}"
%endif
(cd %{buildroot} && find . -path ./lib/systemd -prune -o -type d -print) | sed -e 's/^\.//' -e '/^$/d' | sort > %{app_filelist}
{ rpm -ql filesystem || echo %{default_filesystem}; } | sort > %{filesystem_filelist}
comm -23 %{app_filelist} %{filesystem_filelist} > %{package_filelist}
sed -i -e 's/.*/%dir "&"/' %{package_filelist}
(cd %{buildroot} && find . -not -type d) | sed -e 's/^\.//' -e 's/.*/"&"/' >> %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
sed -i -e 's|"%{license_install_file}"||' -e '/^$/d' %{package_filelist}
%endif
%files -f %{package_filelist}
%if "x%{_rpmdir}/../../LICENSE" != "x"
%license "%{license_install_file}"
%endif
%post
package_type=rpm
%pre
package_type=rpm
%preun
package_type=rpm
%clean
@@ -1,9 +0,0 @@
Package: sparrow
Version: 2.1.1-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: amd64
Provides: sparrow
Description: Sparrow
Depends: libc6, zlib1g
@@ -1,8 +1,8 @@
[Desktop Entry]
Name=Sparrow
Comment=Sparrow
Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Exec=/opt/sparrowwallet/bin/Sparrow %U
Icon=/opt/sparrowwallet/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Finance;Network;
+12
View File
@@ -0,0 +1,12 @@
Package: sparrowwallet
Version: ${version}-1
Section: utils
Maintainer: Craig Raw <mail@sparrowwallet.com>
Priority: optional
Architecture: ${arch}
Provides: sparrowwallet
Conflicts: sparrow (<= 2.1.4)
Replaces: sparrow (<= 2.1.4)
Description: Sparrow Wallet
Depends: libasound2, libbsd0, libc6, libmd0, libx11-6, libxau6, libxcb1, libxdmcp6, libxext6, libxi6, libxrender1, libxtst6, xdg-utils
Installed-Size: ${size}
+4 -4
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# postinst script for sparrow
# postinst script for sparrowwallet
#
# see: dh_installdeb(1)
@@ -22,9 +22,9 @@ package_type=deb
case "$1" in
configure)
xdg-desktop-menu install /opt/sparrow/lib/sparrow-Sparrow.desktop
xdg-mime install /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrow/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
fi
@@ -1,19 +1,20 @@
Summary: Sparrow
Name: sparrow
Version: 2.1.1
Name: sparrowwallet
Version: ${version}
Release: 1
License: ASL 2.0
Vendor: Unknown
%if "x" != "x"
URL:
URL: https://sparrowwallet.com
%endif
%if "x/opt" != "x"
Prefix: /opt
%endif
Provides: sparrow
Provides: sparrowwallet
Obsoletes: sparrow <= 2.1.4
%if "xutils" != "x"
Group: utils
@@ -40,7 +41,7 @@ Requires: xdg-utils
%define default_filesystem / /opt /usr /usr/bin /usr/lib /usr/local /usr/local/bin /usr/local/lib
%description
Sparrow
Sparrow Wallet
%global __os_install_post %{nil}
@@ -50,8 +51,8 @@ Sparrow
%install
rm -rf %{buildroot}
install -d -m 755 %{buildroot}/opt/sparrow
cp -r %{_sourcedir}/opt/sparrow/* %{buildroot}/opt/sparrow
install -d -m 755 %{buildroot}/opt/sparrowwallet
cp -r %{_sourcedir}/opt/sparrowwallet/* %{buildroot}/opt/sparrowwallet
if [ "$(echo %{_sourcedir}/lib/systemd/system/*.service)" != '%{_sourcedir}/lib/systemd/system/*.service' ]; then
install -d -m 755 %{buildroot}/lib/systemd/system
cp %{_sourcedir}/lib/systemd/system/*.service %{buildroot}/lib/systemd/system
@@ -77,9 +78,9 @@ sed -i -e 's/.*/%dir "&"/' %{package_filelist}
%post
package_type=rpm
xdg-desktop-menu install /opt/sparrow/lib/sparrow-Sparrow.desktop
xdg-mime install /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrow/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
fi
@@ -251,9 +252,9 @@ desktop_trace ()
echo "$@"
}
do_if_file_belongs_to_single_package /opt/sparrow/lib/sparrow-Sparrow.desktop xdg-desktop-menu uninstall /opt/sparrow/lib/sparrow-Sparrow.desktop
do_if_file_belongs_to_single_package /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml xdg-mime uninstall /opt/sparrow/lib/sparrow-Sparrow-MimeInfo.xml
do_if_file_belongs_to_single_package /opt/sparrow/lib/sparrow-Sparrow.desktop desktop_uninstall_default_mime_handler sparrow-Sparrow.desktop application/psbt application/bitcoin-transaction application/pgp-signature x-scheme-handler/bitcoin x-scheme-handler/auth47 x-scheme-handler/lightning
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop xdg-desktop-menu uninstall /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml xdg-mime uninstall /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
do_if_file_belongs_to_single_package /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop desktop_uninstall_default_mime_handler sparrowwallet-Sparrow.desktop application/psbt application/bitcoin-transaction application/pgp-signature x-scheme-handler/bitcoin x-scheme-handler/auth47 x-scheme-handler/lightning
%clean
+3 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<string>2.2.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -35,6 +35,8 @@
<string>true</string>
<key>NSCameraUsageDescription</key>
<string>Sparrow requires access to the camera in order to scan QR codes</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Sparrow requires access to the local network in order to connect to your configured server</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -1,9 +1,7 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.google.common.base.Charsets;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteSource;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -80,6 +78,7 @@ public class AppController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(AppController.class);
public static final String DRAG_OVER_CLASS = "drag-over";
public static final int TAB_LABEL_MAX_WIDTH = 300;
public static final double TAB_LABEL_GRAPHIC_OPACITY_INACTIVE = 0.8;
public static final double TAB_LABEL_GRAPHIC_OPACITY_ACTIVE = 0.95;
public static final String LOADING_TRANSACTIONS_MESSAGE = "Loading wallet, select Transactions tab to view...";
@@ -381,7 +380,7 @@ public class AppController implements Initializable {
openWalletsInNewWindows.selectedProperty().bindBidirectional(openWalletsInNewWindowsProperty);
hideEmptyUsedAddressesProperty.set(Config.get().isHideEmptyUsedAddresses());
hideEmptyUsedAddresses.selectedProperty().bindBidirectional(hideEmptyUsedAddressesProperty);
useHdCameraResolutionProperty.set(Config.get().isHdCapture());
useHdCameraResolutionProperty.set(Config.get().getWebcamResolution() == null || Config.get().getWebcamResolution().isWidescreenAspect());
useHdCameraResolution.selectedProperty().bindBidirectional(useHdCameraResolutionProperty);
mirrorCameraImageProperty.set(Config.get().isMirrorCapture());
mirrorCameraImage.selectedProperty().bindBidirectional(mirrorCameraImageProperty);
@@ -573,16 +572,16 @@ public class AppController implements Initializable {
public void installUdevRules(ActionEvent event) {
String commands = """
sudo install -m 644 /opt/sparrow/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
sudo install -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
sudo udevadm control --reload
sudo udevadm trigger
sudo groupadd -f plugdev
sudo usermod -aG plugdev `whoami`
""";
String home = System.getProperty(JPACKAGE_APP_PATH);
if(home != null && !home.startsWith("/opt/sparrow") && home.endsWith("bin/Sparrow")) {
if(home != null && !home.startsWith("/opt/sparrowwallet") && home.endsWith("bin/Sparrow")) {
home = home.replace("bin/Sparrow", "");
commands = commands.replace("/opt/sparrow/", home);
commands = commands.replace("/opt/sparrowwallet/", home);
}
TextAreaDialog dialog = new TextAreaDialog(commands, false);
@@ -633,19 +632,10 @@ public class AppController implements Initializable {
byte[] bytes = Files.readAllBytes(file.toPath());
String name = file.getName();
try {
if(Utils.isHex(bytes) || Utils.isBase64(bytes)) {
addTransactionTab(name, file, new String(bytes, StandardCharsets.UTF_8).trim());
} else {
addTransactionTab(name, file, bytes);
} catch(ParseException e) {
ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
ByteSource byteSource = new ByteSource() {
@Override
public InputStream openStream() {
return inputStream;
}
};
String text = byteSource.asCharSource(Charsets.UTF_8).read().trim();
addTransactionTab(name, file, text);
}
} catch(IOException e) {
showErrorDialog("Error opening file", e.getMessage());
@@ -955,7 +945,11 @@ public class AppController implements Initializable {
public void useHdCameraResolution(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setHdCapture(item.isSelected());
if(Config.get().getWebcamResolution().isStandardAspect() && item.isSelected()) {
Config.get().setWebcamResolution(WebcamResolution.HD);
} else if(Config.get().getWebcamResolution().isWidescreenAspect() && !item.isSelected()) {
Config.get().setWebcamResolution(WebcamResolution.VGA);
}
}
public void mirrorCameraImage(ActionEvent event) {
@@ -1263,6 +1257,10 @@ public class AppController implements Initializable {
}
private void addImportedWallet(Wallet wallet) {
if(AppServices.disallowAnyInvalidDerivationPaths(wallet)) {
return;
}
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getBirthDate());
nameDlg.initOwner(rootStack.getScene().getWindow());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
@@ -1378,7 +1376,7 @@ public class AppController implements Initializable {
public void exportWallet(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm);
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm, getSelectedWalletForms());
dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> wallet = dlg.showAndWait();
if(wallet.isPresent()) {
@@ -1480,6 +1478,7 @@ public class AppController implements Initializable {
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
if(event.getSource() instanceof File file) {
downloadVerifierDialog.setInitialFile(file);
downloadVerifierDialog.setSignatureFile(file);
}
return;
@@ -1992,8 +1991,13 @@ public class AppController implements Initializable {
glyph.setFontSize(10.0);
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
Label tabLabel = new Label(tabName);
tabLabel.setMaxWidth(TAB_LABEL_MAX_WIDTH);
tabLabel.setGraphic(glyph);
tabLabel.setGraphicTextGap(5.0);
if(TextUtils.computeTextWidth(tabLabel.getFont(), tabName, 0.0D) > TAB_LABEL_MAX_WIDTH) {
Tooltip tooltip = new Tooltip(tabName);
tabLabel.setTooltip(tooltip);
}
tab.setGraphic(tabLabel);
tab.setContextMenu(getTabContextMenu(tab));
tab.setClosable(true);
@@ -2630,7 +2634,6 @@ public class AppController implements Initializable {
}
});
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
String walletName = event.getWallet().getFullDisplayName();
if(walletName.length() > 40) {
walletName = walletName.substring(0, 40) + "...";
@@ -2639,10 +2642,10 @@ public class AppController implements Initializable {
Notifications notificationBuilder = Notifications.create()
.title("Sparrow - " + walletName)
.text(text)
.graphic(new ImageView(image))
.graphic(new DialogImage(DialogImage.Type.SPARROW))
.hideAfter(Duration.seconds(15))
.position(Pos.TOP_RIGHT)
.threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new ImageView(image)))
.threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new DialogImage(DialogImage.Type.SPARROW)))
.onAction(e -> selectTab(event.getWallet()));
//If controlsfx can't find our window, we must set the window ourselves (unfortunately notification is then shown within this window)
@@ -2883,6 +2886,7 @@ public class AppController implements Initializable {
}
} else if(event.isCompleted()) {
serverToggle.setDisable(false);
statusBar.setProgress(0);
if(statusBar.getText().startsWith("Scanning...")) {
statusBar.setText("");
}
@@ -3156,7 +3160,7 @@ public class AppController implements Initializable {
@Subscribe
public void webcamResolutionChanged(WebcamResolutionChangedEvent event) {
useHdCameraResolutionProperty.set(event.isHdResolution());
useHdCameraResolutionProperty.set(event.getResolution().isWidescreenAspect());
}
@Subscribe
@@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.DialogImage;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.Auth47;
@@ -25,6 +26,8 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.subjects.PublishSubject;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
@@ -42,7 +45,6 @@ import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.Dialog;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.text.Font;
import javafx.stage.Screen;
@@ -66,6 +68,8 @@ import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
@@ -104,6 +108,8 @@ public class AppServices {
private TrayManager trayManager;
private final PublishSubject<NewBlockEvent> newBlockSubject = PublishSubject.create();
private static Image windowIcon;
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
@@ -126,6 +132,8 @@ public class AppServices {
private static BlockHeader latestBlockHeader;
private static final Map<Integer, BlockSummary> blockSummaries = new ConcurrentHashMap<>();
private static Map<Integer, Double> targetBlockFeeRates;
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
@@ -182,6 +190,12 @@ public class AppServices {
private AppServices(Application application, InteractionServices interactionServices) {
this.application = application;
this.interactionServices = interactionServices;
newBlockSubject.buffer(4, TimeUnit.SECONDS)
.filter(newBlockEvents -> !newBlockEvents.isEmpty())
.observeOn(JavaFxScheduler.platform())
.subscribe(this::fetchBlockSummaries, exception -> log.error("Error fetching block summaries", exception));
EventManager.get().register(this);
}
@@ -261,7 +275,7 @@ public class AppServices {
}
if(Tor.getDefault() != null) {
Tor.getDefault().getTorManager().destroy(true, success -> {});
Tor.getDefault().close();
}
}
@@ -291,12 +305,6 @@ public class AppServices {
if(event != null) {
EventManager.get().post(event);
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(event instanceof ConnectionEvent && feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
EventManager.get().post(new FeeRatesSourceChangedEvent(feeRatesSource));
}
});
connectionService.setOnFailed(failEvent -> {
//Close connection here to create a new transport next time we try
@@ -480,6 +488,26 @@ public class AppServices {
}
}
private void fetchFeeRates() {
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
}
private void fetchBlockSummaries(List<NewBlockEvent> newBlockEvents) {
if(isConnected()) {
ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents);
blockSummaryService.setOnSucceeded(_ -> {
EventManager.get().post(blockSummaryService.getValue());
});
blockSummaryService.setOnFailed(failedState -> {
log.error("Error fetching block summaries", failedState.getSource().getException());
});
blockSummaryService.start();
}
}
public static boolean isTorRunning() {
return Tor.getDefault() != null;
}
@@ -705,6 +733,10 @@ public class AppServices {
return latestBlockHeader;
}
public static Map<Integer, BlockSummary> getBlockSummaries() {
return blockSummaries;
}
public static Double getDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget);
@@ -1095,8 +1127,7 @@ public class AppServices {
walletChoiceDialog.initOwner(getActiveWindow());
walletChoiceDialog.setTitle("Choose Wallet");
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
Image image = new Image("/image/sparrow-small.png");
walletChoiceDialog.getDialogPane().setGraphic(new ImageView(image));
walletChoiceDialog.getDialogPane().setGraphic(new DialogImage(DialogImage.Type.SPARROW));
setStageIcon(walletChoiceDialog.getDialogPane().getScene().getWindow());
moveToActiveWindowScreen(walletChoiceDialog);
Optional<Wallet> optWallet = walletChoiceDialog.showAndWait();
@@ -1108,6 +1139,31 @@ public class AppServices {
return wallet;
}
public static boolean disallowAnyInvalidDerivationPaths(Wallet wallet) {
Optional<ScriptType> optInvalidScriptType = wallet.getKeystores().stream()
.filter(keystore -> keystore.getKeyDerivation() != null)
.map(keystore -> wallet.getOtherScriptTypeMatchingDerivation(keystore.getKeyDerivation().getDerivationPath()))
.filter(Optional::isPresent).map(Optional::get).findFirst();
if(optInvalidScriptType.isPresent()) {
ScriptType invalidScriptType = optInvalidScriptType.get();
boolean includePolicyType = !wallet.getScriptType().getAllowedPolicyTypes().getFirst().equals(invalidScriptType.getAllowedPolicyTypes().getFirst());
Optional<ButtonType> optType = AppServices.showWarningDialog("Invalid derivation path", "This wallet is using the derivation path for " +
invalidScriptType.getDescription(includePolicyType) + ", instead of the derivation path for its defined script type of " + wallet.getScriptType().getDescription(includePolicyType) +
". \n\nDisable derivation path validation to import this wallet?", ButtonType.NO, ButtonType.YES);
if(optType.isPresent()) {
if(optType.get() == ButtonType.YES) {
Config.get().setValidateDerivationPaths(false);
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(true));
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, Boolean.toString(true));
} else {
return true;
}
}
}
return false;
}
public static final List<Network> WHIRLPOOL_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
public static boolean isWhirlpoolCompatible(Wallet wallet) {
@@ -1123,7 +1179,8 @@ public class AppServices {
public static boolean isWhirlpoolPostmixCompatible(Wallet wallet) {
return WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1;
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().getFirst().getWalletModel() != WalletModel.BITBOX_02; //BitBox02 does not support high account numbers
}
public static List<Wallet> addWhirlpoolWallets(Wallet decryptedWallet, String walletId, Storage storage) {
@@ -1159,6 +1216,16 @@ public class AppServices {
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
fetchFeeRates();
}
if(!blockSummaries.containsKey(currentBlockHeight)) {
fetchBlockSummaries(Collections.emptyList());
}
}
@Subscribe
@@ -1173,6 +1240,15 @@ public class AppServices {
latestBlockHeader = event.getBlockHeader();
String status = "Updating to new block height " + event.getHeight();
EventManager.get().post(new StatusEvent(status));
newBlockSubject.onNext(event);
}
@Subscribe
public void blockSummary(BlockSummaryEvent event) {
blockSummaries.putAll(event.getBlockSummaryMap());
if(AppServices.currentBlockHeight != null) {
blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5);
}
}
@Subscribe
@@ -1190,10 +1266,8 @@ public class AppServices {
@Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
//Perform once-off fee rates retrieval to immediately change displayed rates
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
fetchFeeRates();
fetchBlockSummaries(Collections.emptyList());
}
@Subscribe
@@ -0,0 +1,76 @@
package com.sparrowwallet.sparrow;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;
public class BlockSummary implements Comparable<BlockSummary> {
private final Integer height;
private final Date timestamp;
private final Double medianFee;
private final Integer transactionCount;
private final Integer weight;
public BlockSummary(Integer height, Date timestamp) {
this(height, timestamp, null, null, null);
}
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount, Integer weight) {
this.height = height;
this.timestamp = timestamp;
this.medianFee = medianFee;
this.transactionCount = transactionCount;
this.weight = weight;
}
public Integer getHeight() {
return height;
}
public Date getTimestamp() {
return timestamp;
}
public Optional<Double> getMedianFee() {
return medianFee == null ? Optional.empty() : Optional.of(medianFee);
}
public Optional<Integer> getTransactionCount() {
return transactionCount == null ? Optional.empty() : Optional.of(transactionCount);
}
public Optional<Integer> getWeight() {
return weight == null ? Optional.empty() : Optional.of(weight);
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public String getElapsed() {
long elapsed = calculateElapsedSeconds(getTimestamp().getTime());
if(elapsed < 0) {
return "now";
} else if(elapsed < 60) {
return elapsed + "s";
} else if(elapsed < 3600) {
return elapsed / 60 + "m";
} else if(elapsed < 86400) {
return elapsed / 3600 + "h";
} else {
return elapsed / 86400 + "d";
}
}
public String toString() {
return getElapsed() + ":" + getMedianFee();
}
@Override
public int compareTo(BlockSummary o) {
return o.height - height;
}
}
@@ -72,10 +72,6 @@ public class SparrowDesktop extends Application {
Config.get().setServerType(ServerType.ELECTRUM_SERVER);
}
if(Config.get().getHdCapture() == null && OsType.getCurrent() == OsType.MACOS) {
Config.get().setHdCapture(Boolean.TRUE);
}
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
@@ -18,7 +18,7 @@ import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "2.1.1";
public static final String APP_VERSION = "2.2.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";
@@ -21,7 +21,7 @@ public class WelcomeDialog extends Dialog<Mode> {
welcomeController.initializeView();
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
dialogPane.setPrefHeight(540);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
@@ -0,0 +1,367 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.BlockSummary;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.scene.Group;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Duration;
import org.girod.javafx.svgimage.SVGImage;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
public class BlockCube extends Group {
public static final List<Integer> MEMPOOL_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, 900, 1000, 1200, 1400, 1600, 1800, 2000);
public static final double CUBE_SIZE = 60;
private final IntegerProperty weightProperty = new SimpleIntegerProperty(0);
private final DoubleProperty medianFeeProperty = new SimpleDoubleProperty(-Double.MAX_VALUE);
private final IntegerProperty heightProperty = new SimpleIntegerProperty(0);
private final IntegerProperty txCountProperty = new SimpleIntegerProperty(0);
private final LongProperty timestampProperty = new SimpleLongProperty(System.currentTimeMillis());
private final StringProperty elapsedProperty = new SimpleStringProperty("");
private final BooleanProperty confirmedProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<FeeRatesSource> feeRatesSource = new SimpleObjectProperty<>(null);
private Polygon front;
private Rectangle unusedArea;
private Rectangle usedArea;
private final Text heightText = new Text();
private final Text medianFeeText = new Text();
private final Text unitsText = new Text();
private final TextFlow medianFeeTextFlow = new TextFlow();
private final Text txCountText = new Text();
private final Text elapsedText = new Text();
private final Group feeRateIcon = new Group();
public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) {
getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube");
this.confirmedProperty.set(confirmed);
this.feeRatesSource.set(Config.get().getFeeRatesSource());
this.weightProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeProperty.addListener((_, _, newValue) -> {
medianFeeText.setText(newValue.doubleValue() < 0.0d ? "" : "~" + Math.round(Math.max(newValue.doubleValue(), 1.0d)));
unitsText.setText(newValue.doubleValue() < 0.0d ? "" : " s/vb");
double medianFeeWidth = TextUtils.computeTextWidth(medianFeeText.getFont(), medianFeeText.getText(), 0.0d);
double unitsWidth = TextUtils.computeTextWidth(unitsText.getFont(), unitsText.getText(), 0.0d);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeWidth + unitsWidth)) / 2);
});
this.txCountProperty.addListener((_, _, newValue) -> {
txCountText.setText(newValue.intValue() == 0 ? "" : newValue + " txes");
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
});
this.timestampProperty.addListener((_, _, newValue) -> {
elapsedProperty.set(getElapsed(newValue.longValue()));
});
this.elapsedProperty.addListener((_, _, newValue) -> {
elapsedText.setText(isConfirmed() ? newValue : "In ~10m");
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
});
this.heightProperty.addListener((_, _, newValue) -> {
heightText.setText(newValue.intValue() == 0 ? "" : String.valueOf(newValue));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
});
this.confirmedProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.feeRatesSource.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeText.textProperty().addListener((_, _, _) -> {
pulse();
});
if(weight != null) {
this.weightProperty.set(weight);
}
if(medianFee != null) {
this.medianFeeProperty.set(medianFee);
}
if(height != null) {
this.heightProperty.set(height);
}
if(txCount != null) {
this.txCountProperty.set(txCount);
}
if(timestamp != null) {
this.timestampProperty.set(timestamp);
}
drawCube();
}
private void drawCube() {
double depth = CUBE_SIZE * 0.2;
double perspective = CUBE_SIZE * 0.04;
front = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE, CUBE_SIZE, 0, CUBE_SIZE);
front.getStyleClass().add("block-front");
front.setFill(null);
unusedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
unusedArea.getStyleClass().add("block-unused");
usedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
usedArea.getStyleClass().add("block-used");
Group frontFaceGroup = new Group(front, unusedArea, usedArea);
Polygon top = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE - depth - perspective, -depth, -depth, -depth);
top.getStyleClass().add("block-top");
top.setStroke(null);
Polygon left = new Polygon(0, 0, -depth, -depth, -depth, CUBE_SIZE - depth - perspective, 0, CUBE_SIZE);
left.getStyleClass().add("block-left");
left.setStroke(null);
updateFill();
heightText.getStyleClass().add("block-height");
heightText.setFont(new Font(11));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
heightText.setY(-24);
medianFeeText.getStyleClass().add("block-text");
medianFeeText.setFont(Font.font(null, FontWeight.BOLD, 11));
unitsText.getStyleClass().add("block-text");
unitsText.setFont(new Font(10));
medianFeeTextFlow.getChildren().addAll(medianFeeText, unitsText);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeText.getLayoutBounds().getWidth() + unitsText.getLayoutBounds().getWidth())) / 2);
medianFeeTextFlow.setTranslateY(7);
txCountText.getStyleClass().add("block-text");
txCountText.setFont(new Font(10));
txCountText.setOpacity(0.7);
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
txCountText.setY(34);
feeRateIcon.setTranslateX(((CUBE_SIZE * 0.7) - 14) / 2);
feeRateIcon.setTranslateY(-36);
elapsedText.getStyleClass().add("block-text");
elapsedText.setFont(new Font(10));
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
elapsedText.setY(50);
getChildren().addAll(frontFaceGroup, top, left, heightText, medianFeeTextFlow, txCountText, feeRateIcon, elapsedText);
}
private void updateFill() {
if(isConfirmed()) {
getStyleClass().removeAll("block-unconfirmed");
if(!getStyleClass().contains("block-confirmed")) {
getStyleClass().add("block-confirmed");
}
double startY = 1 - weightProperty.doubleValue() / (Transaction.MAX_BLOCK_SIZE_VBYTES * Transaction.WITNESS_SCALE_FACTOR);
double startYAbsolute = startY * BlockCube.CUBE_SIZE;
unusedArea.setHeight(startYAbsolute);
unusedArea.setStyle(null);
usedArea.setY(startYAbsolute);
usedArea.setHeight(CUBE_SIZE - startYAbsolute);
usedArea.setVisible(true);
heightText.setVisible(true);
feeRateIcon.getChildren().clear();
} else {
getStyleClass().removeAll("block-confirmed");
if(!getStyleClass().contains("block-unconfirmed")) {
getStyleClass().add("block-unconfirmed");
}
usedArea.setVisible(false);
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
heightText.setVisible(false);
if(feeRatesSource.get() != null) {
SVGImage svgImage = feeRatesSource.get().getSVGImage();
if(svgImage != null) {
feeRateIcon.getChildren().setAll(feeRatesSource.get().getSVGImage());
} else {
feeRateIcon.getChildren().clear();
}
}
}
}
public void pulse() {
if(isConfirmed()) {
return;
}
if(unusedArea != null) {
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
}
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(opacityProperty(), 1.0)),
new KeyFrame(Duration.millis(500), new KeyValue(opacityProperty(), 0.7)),
new KeyFrame(Duration.millis(1000), new KeyValue(opacityProperty(), 1.0))
);
timeline.setCycleCount(1);
timeline.play();
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public static String getElapsed(long timestampUtc) {
long elapsed = calculateElapsedSeconds(timestampUtc);
if(elapsed < 60) {
return "Just now";
} else if(elapsed < 3600) {
return Math.round(elapsed / 60f) + "m ago";
} else if(elapsed < 86400) {
return Math.round(elapsed / 3600f) + "h ago";
} else {
return Math.round(elapsed / 86400d) + "d ago";
}
}
private String getFeeRateStyleName() {
double rate = getMedianFee();
int[] feeRateInterval = getFeeRateInterval(rate);
if(feeRateInterval[1] == Integer.MAX_VALUE) {
return "VSIZE2000-2200_COLOR";
}
int[] nextRateInterval = getFeeRateInterval(rate * 2);
String from = "VSIZE" + feeRateInterval[0] + "-" + feeRateInterval[1] + "_COLOR";
String to = "VSIZE" + nextRateInterval[0] + "-" + (nextRateInterval[1] == Integer.MAX_VALUE ? "2200" : nextRateInterval[1]) + "_COLOR";
return "linear-gradient(from 75% 0% to 100% 0%, " + from + " 0%, " + to + " 100%, " + from +")";
}
private int[] getFeeRateInterval(double medianFee) {
for(int i = 0; i < MEMPOOL_FEE_RATES_INTERVALS.size(); i++) {
int feeRate = MEMPOOL_FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == MEMPOOL_FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : MEMPOOL_FEE_RATES_INTERVALS.get(i + 1));
if(feeRate <= medianFee && nextFeeRate > medianFee) {
return new int[] { feeRate, nextFeeRate };
}
}
return new int[] { 1, 2 };
}
public int getWeight() {
return weightProperty.get();
}
public IntegerProperty weightProperty() {
return weightProperty;
}
public void setWeight(int weight) {
weightProperty.set(weight);
}
public double getMedianFee() {
return medianFeeProperty.get();
}
public DoubleProperty medianFee() {
return medianFeeProperty;
}
public void setMedianFee(double medianFee) {
medianFeeProperty.set(medianFee);
}
public int getHeight() {
return heightProperty.get();
}
public IntegerProperty heightProperty() {
return heightProperty;
}
public void setHeight(int height) {
heightProperty.set(height);
}
public int getTxCount() {
return txCountProperty.get();
}
public IntegerProperty txCountProperty() {
return txCountProperty;
}
public void setTxCount(int txCount) {
txCountProperty.set(txCount);
}
public long getTimestamp() {
return timestampProperty.get();
}
public LongProperty timestampProperty() {
return timestampProperty;
}
public void setTimestamp(long timestamp) {
timestampProperty.set(timestamp);
}
public String getElapsed() {
return elapsedProperty.get();
}
public StringProperty elapsedProperty() {
return elapsedProperty;
}
public void setElapsed(String elapsed) {
elapsedProperty.set(elapsed);
}
public boolean isConfirmed() {
return confirmedProperty.get();
}
public BooleanProperty confirmedProperty() {
return confirmedProperty;
}
public void setConfirmed(boolean confirmed) {
confirmedProperty.set(confirmed);
}
public FeeRatesSource getFeeRatesSource() {
return feeRatesSource.get();
}
public ObjectProperty<FeeRatesSource> feeRatesSourceProperty() {
return feeRatesSource;
}
public void setFeeRatesSource(FeeRatesSource feeRatesSource) {
this.feeRatesSource.set(feeRatesSource);
}
public static BlockCube fromBlockSummary(BlockSummary blockSummary) {
return new BlockCube(blockSummary.getWeight().orElse(0), blockSummary.getMedianFee().orElse(-1.0d), blockSummary.getHeight(),
blockSummary.getTransactionCount().orElse(0), blockSummary.getTimestamp().getTime(), true);
}
}
@@ -48,7 +48,7 @@ public class CardImportPane extends TitledDescriptionPane {
private final SimpleStringProperty pin = new SimpleStringProperty("");
public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) {
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png");
super(importer.getName(), "Place card on reader", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), importer.getWalletModel());
this.importer = importer;
this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation();
}
@@ -225,6 +225,13 @@ public class CoinTreeTable extends TreeTableView<Entry> {
walletTableEvents.skip(3, TimeUnit.SECONDS).subscribe(event -> {
event.getWallet().getWalletTables().put(event.getTableType(), event.getWalletTable());
EventManager.get().post(event);
//Reset pref widths here so window resizes don't cause reversion to previously set pref widths
Double[] widths = event.getWalletTable().getWidths();
for(int i = 0; i < getColumns().size(); i++) {
TreeTableColumn<Entry, ?> column = getColumns().get(i);
column.setPrefWidth(widths != null && getColumns().size() == widths.length ? widths[i] : STANDARD_WIDTH);
}
});
}
@@ -75,7 +75,7 @@ public class DevicePane extends TitledDescriptionPane {
private boolean defaultDevice;
public DevicePane(Wallet wallet, Device device, boolean defaultDevice, KeyDerivation requiredDerivation) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.IMPORT;
this.wallet = wallet;
this.psbt = null;
@@ -102,7 +102,7 @@ public class DevicePane extends TitledDescriptionPane {
}
public DevicePane(Wallet wallet, PSBT psbt, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = wallet;
this.psbt = psbt;
@@ -129,7 +129,7 @@ public class DevicePane extends TitledDescriptionPane {
}
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
this.wallet = wallet;
this.psbt = null;
@@ -152,7 +152,7 @@ public class DevicePane extends TitledDescriptionPane {
}
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
this.psbt = null;
@@ -179,7 +179,7 @@ public class DevicePane extends TitledDescriptionPane {
}
public DevicePane(Wallet wallet, List<StandardAccount> availableAccounts, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = DeviceOperation.DISCOVER_KEYSTORES;
this.wallet = wallet;
this.psbt = null;
@@ -202,7 +202,7 @@ public class DevicePane extends TitledDescriptionPane {
}
public DevicePane(DeviceOperation deviceOperation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
super(device.getModel().toDisplayString(), "", "", device.getModel());
this.deviceOperation = deviceOperation;
this.wallet = null;
this.psbt = null;
@@ -0,0 +1,85 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.StackPane;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.util.Locale;
public class DialogImage extends StackPane {
private static final Logger log = LoggerFactory.getLogger(DialogImage.class);
public static final int WIDTH = 50;
public static final int HEIGHT = 50;
public ObjectProperty<DialogImage.Type> typeProperty = new SimpleObjectProperty<>();
public DialogImage() {
setPrefSize(WIDTH, HEIGHT);
this.typeProperty.addListener((observable, oldValue, type) -> {
refresh(type);
});
}
public DialogImage(@NamedArg("type") Type type) {
this();
this.typeProperty.set(type);
}
public void refresh() {
Type type = getType();
refresh(type);
}
protected void refresh(Type type) {
SVGImage svgImage;
if(Config.get().getTheme() == Theme.DARK) {
svgImage = loadSVGImage("/image/dialog/" + type.name().toLowerCase(Locale.ROOT) + "-invert.svg");
} else {
svgImage = loadSVGImage("/image/dialog/" + type.name().toLowerCase(Locale.ROOT) + ".svg");
}
if(svgImage != null) {
getChildren().clear();
getChildren().add(svgImage);
}
}
public Type getType() {
return typeProperty.get();
}
public ObjectProperty<Type> typeProperty() {
return typeProperty;
}
public void setType(Type type) {
this.typeProperty.set(type);
}
private SVGImage loadSVGImage(String imageName) {
try {
URL url = AppServices.class.getResource(imageName);
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
log.error("Could not find image " + imageName);
}
return null;
}
public enum Type {
SPARROW, SEED, PAYNYM, BORDERWALLETS, USERADD, WHIRLPOOL;
}
}
@@ -56,13 +56,15 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static final List<String> MANIFEST_EXTENSIONS = List.of("txt");
private static final List<String> PUBLIC_KEY_EXTENSIONS = List.of("asc");
private static final List<String> MACOS_RELEASE_EXTENSIONS = List.of("dmg");
private static final List<String> WINDOWS_RELEASE_EXTENSIONS = List.of("exe", "zip");
private static final List<String> WINDOWS_RELEASE_EXTENSIONS = List.of("exe", "msi", "zip");
private static final List<String> LINUX_RELEASE_EXTENSIONS = List.of("deb", "rpm", "tar.gz");
private static final List<String> DISK_IMAGE_EXTENSIONS = List.of("img", "bin", "dfu");
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
private static final String SPARROW_SIGNATURE_SUFFIX = "-manifest.txt.asc";
private static final String SPARROW_RELEASE_ALT_PREFIX = "sparrow_";
private static final String SPARROW_MANIFEST_SUFFIX = "-manifest.txt";
private static final String SPARROW_SIGNATURE_SUFFIX = SPARROW_MANIFEST_SUFFIX + ".asc";
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
private static final long MIN_VALID_SPARROW_RELEASE_SIZE = 10 * 1024 * 1024;
@@ -70,6 +72,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private final ObjectProperty<File> manifest = new SimpleObjectProperty<>();
private final ObjectProperty<File> publicKey = new SimpleObjectProperty<>();
private final ObjectProperty<File> release = new SimpleObjectProperty<>();
private final ObjectProperty<File> initial = new SimpleObjectProperty<>();
private final BooleanProperty manifestDisabled = new SimpleBooleanProperty();
private final BooleanProperty publicKeyDisabled = new SimpleBooleanProperty();
@@ -81,7 +84,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static File lastFileParent;
public DownloadVerifierDialog(File initialSignatureFile) {
public DownloadVerifierDialog(File initialFile) {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
@@ -223,11 +226,17 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
});
release.addListener((observable, oldValue, releaseFile) -> {
if(releaseFile != null) {
initial.set(null);
}
verify();
});
if(initialSignatureFile != null) {
javafx.application.Platform.runLater(() -> signature.set(initialSignatureFile));
if(initialFile != null) {
javafx.application.Platform.runLater(() -> {
initial.set(initialFile);
signature.set(initialFile);
});
}
}
@@ -292,7 +301,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
publicKeyDisabled.set(true);
}
if(manifest.get().equals(release.get())) {
if(manifest.get().equals(release.get()) && !isSparrowManifest(manifest.get())) {
manifestDisabled.set(true);
releaseHash.setText("No hash required, signature signs release file directly");
releaseHash.setGraphic(GlyphUtils.getSuccessGlyph());
@@ -455,7 +464,8 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
}
if(providedFile.getName().toLowerCase(Locale.ROOT).startsWith(SPARROW_RELEASE_PREFIX)) {
String providedName = providedFile.getName().toLowerCase(Locale.ROOT);
if(providedName.startsWith(SPARROW_RELEASE_PREFIX) || providedName.startsWith(SPARROW_RELEASE_ALT_PREFIX)) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
if(matcher.find()) {
String version = matcher.group();
@@ -482,6 +492,22 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
private File findReleaseFile(File manifestFile, Map<File, String> manifestMap) {
File initialFile = initial.get();
if(initialFile != null && initialFile.exists()) {
for(File file : manifestMap.keySet()) {
if(initialFile.getName().equals(file.getName())) {
return initialFile;
}
}
List<List<String>> allExtensionLists = List.of(MACOS_RELEASE_EXTENSIONS, WINDOWS_RELEASE_EXTENSIONS, LINUX_RELEASE_EXTENSIONS, DISK_IMAGE_EXTENSIONS, ARCHIVE_EXTENSIONS);
for(List<String> extensions : allExtensionLists) {
if(extensions.stream().anyMatch(ext -> initialFile.getName().toLowerCase(Locale.ROOT).endsWith(ext))) {
return initialFile;
}
}
}
List<String> releaseExtensions = getReleaseFileExtensions();
List<List<String>> extensionLists = List.of(releaseExtensions, DISK_IMAGE_EXTENSIONS, ARCHIVE_EXTENSIONS, List.of(""));
@@ -565,7 +591,7 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
}
}
if(name.startsWith(SPARROW_RELEASE_PREFIX) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
if((name.startsWith(SPARROW_RELEASE_PREFIX) || name.startsWith(SPARROW_RELEASE_ALT_PREFIX)) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
@@ -574,10 +600,18 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
return false;
}
public static boolean isSparrowManifest(File manifestFile) {
return manifestFile.getName().startsWith(SPARROW_RELEASE_PREFIX) && manifestFile.getName().endsWith(SPARROW_MANIFEST_SUFFIX);
}
public void setSignatureFile(File signatureFile) {
signature.set(signatureFile);
}
public void setInitialFile(File initialFile) {
initial.set(initialFile);
}
private static class Header extends GridPane {
public Header() {
setMaxWidth(Double.MAX_VALUE);
@@ -598,15 +632,8 @@ public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
vBox.getChildren().addAll(headerLabel, descriptionLabel);
add(vBox, 0, 0);
StackPane graphicContainer = new StackPane();
StackPane graphicContainer = new DialogImage(DialogImage.Type.SPARROW);
graphicContainer.getStyleClass().add("graphic-container");
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);
graphicContainer.getChildren().add(imageView);
}
add(graphicContainer, 1, 0);
ColumnConstraints textColumn = new ColumnConstraints();
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.FileImport;
@@ -44,8 +45,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
private final boolean fileFormatAvailable;
protected List<Wallet> wallets;
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable, boolean fileFormatAvailable) {
super(title, description, content, imageUrl);
public FileImportPane(FileImport importer, String title, String description, String content, WalletModel walletModel, boolean scannable, boolean fileFormatAvailable) {
super(title, description, content, walletModel);
this.importer = importer;
this.scannable = scannable;
this.fileFormatAvailable = fileFormatAvailable;
@@ -37,7 +37,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
private final boolean file;
public FileKeystoreExportPane(Keystore keystore, KeystoreFileExport exporter) {
super(exporter.getName(), "Keystore export", exporter.getKeystoreExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
super(exporter.getName(), "Keystore export", exporter.getKeystoreExportDescription(), exporter.getWalletModel());
this.keystore = keystore;
this.exporter = exporter;
this.scannable = exporter.isKeystoreExportScannable();
@@ -16,7 +16,7 @@ public class FileKeystoreImportPane extends FileImportPane {
private final KeyDerivation requiredDerivation;
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer, KeyDerivation requiredDerivation) {
super(importer, importer.getName(), "Key import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
super(importer, importer.getName(), "Key import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), importer.getWalletModel(), importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
this.wallet = wallet;
this.importer = importer;
this.requiredDerivation = requiredDerivation;
@@ -41,7 +41,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
private final boolean file;
public FileWalletExportPane(Wallet wallet, WalletExport exporter) {
super(exporter.getName(), "Wallet export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
super(exporter.getName(), "Wallet export", exporter.getWalletExportDescription(), exporter.getWalletModel());
this.wallet = wallet;
this.exporter = exporter;
this.scannable = exporter.isWalletExportScannable();
@@ -168,7 +168,7 @@ 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) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else if(exporter instanceof Bip129) {
} else if(exporter instanceof Bip129 || exporter instanceof WalletLabels) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
@@ -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(), importer.isWalletImportFileFormatAvailable());
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), importer.getWalletModel(), importer.isWalletImportScannable(), importer.isWalletImportFileFormatAvailable());
this.importer = importer;
}
@@ -42,7 +42,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
private String password;
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), importer.getWalletModel(), importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
this.importer = importer;
}
@@ -38,12 +38,23 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> implements Confirm
if(empty) {
setText(null);
setGraphic(null);
setTooltip(null);
} else {
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
setText(label);
setContextMenu(new LabelContextMenu(entry, label));
double width = label == null || label.length() < 20 ? 0.0 : TextUtils.computeTextWidth(getFont(), label, 0.0D);
if(width > getTableColumn().getWidth()) {
Tooltip tooltip = new Tooltip(label);
tooltip.setMaxWidth(getTreeTableView().getWidth());
tooltip.setWrapText(true);
setTooltip(tooltip);
} else {
setTooltip(null);
}
}
}
@@ -121,7 +132,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> implements Confirm
return confirmationsProperty;
}
private static class LabelContextMenu extends ContextMenu {
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label");
copyLabel.setOnAction(AE -> {
@@ -141,6 +152,13 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> implements Confirm
}
});
getItems().add(pasteLabel);
MenuItem editLabel = new MenuItem("Edit Label...");
editLabel.setOnAction(AE -> {
hide();
startEdit();
});
getItems().add(editLabel);
}
}
}
@@ -118,14 +118,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title);
Image image = new Image("image/seed.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
dialogPane.setGraphic(new WalletModelImage(WalletModel.SEED));
VBox vBox = new VBox();
vBox.setSpacing(20);
@@ -50,8 +50,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
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));
dialogPane.setGraphic(new DialogImage(DialogImage.Type.BORDERWALLETS));
String[][] emptyWordGrid = new String[128][GRID_COLUMN_COUNT];
Grid grid = getGrid(emptyWordGrid);
@@ -19,7 +19,7 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
private final DeterministicSeed.Type type;
public MnemonicKeystoreDisplayPane(Keystore keystore) {
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() && (keystore.getSeed().getPassphrase() == null || keystore.getSeed().getPassphrase().length() > 0) ? "Passphrase entered" : "No passphrase", "", "image/" + WalletModel.SEED.getType() + ".png");
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() && (keystore.getSeed().getPassphrase() == null || keystore.getSeed().getPassphrase().length() > 0) ? "Passphrase entered" : "No passphrase", "", WalletModel.SEED);
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
this.type = keystore.getSeed().getType();
@@ -19,7 +19,7 @@ public class MnemonicKeystoreEntryPane extends MnemonicKeystorePane {
private boolean generated;
public MnemonicKeystoreEntryPane(String name, int numWords) {
super(name, "Enter seed words", "", "image/" + WalletModel.SEED.getType() + ".png");
super(name, "Enter seed words", "", WalletModel.SEED);
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
@@ -45,7 +45,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private List<String> generatedMnemonicCode;
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Create or enter seed", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
super(importer.getName(), "Create or enter seed", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
@@ -3,6 +3,7 @@ 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 com.sparrowwallet.drongo.wallet.slip39.Slip39MnemonicCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
@@ -51,8 +52,8 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
protected final SimpleStringProperty passphraseProperty = new SimpleStringProperty("");
protected IntegerProperty defaultWordSizeProperty;
public MnemonicKeystorePane(String title, String description, String content, String imageUrl) {
super(title, description, content, imageUrl);
public MnemonicKeystorePane(String title, String description, String content, WalletModel walletModel) {
super(title, description, content, walletModel);
}
@Override
@@ -320,6 +321,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
}
};
wordField.setMaxWidth(100);
wordField.setAccessibleText("Word " + (wordNumber + 1));
TextFormatter<?> formatter = new TextFormatter<>((TextFormatter.Change change) -> {
String text = change.getText();
// if text was added, fix the text to fit the requirements
@@ -44,7 +44,7 @@ public class MnemonicShareKeystoreImportPane extends MnemonicKeystorePane {
private int currentShare;
public MnemonicShareKeystoreImportPane(Wallet wallet, KeystoreMnemonicShareImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Enter seed share", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
super(importer.getName(), "Enter seed share", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
@@ -41,7 +41,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
private Button importButton;
public MnemonicWalletKeystoreImportPane(KeystoreMnemonicImport importer) {
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.importer = importer;
}
@@ -55,7 +55,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
protected List<Node> createRightButtons() {
discoverButton = new Button("Discover Wallet");
discoverButton.setDisable(true);
discoverButton.setDefaultButton(true);
discoverButton.setDefaultButton(AppServices.onlineProperty().get());
discoverButton.managedProperty().bind(discoverButton.visibleProperty());
discoverButton.setOnAction(event -> {
discoverWallet();
@@ -66,6 +66,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
importButton = new Button("Import Wallet");
importButton.setDisable(true);
importButton.setDefaultButton(!AppServices.onlineProperty().get());
importButton.managedProperty().bind(importButton.visibleProperty());
importButton.visibleProperty().bind(discoverButton.visibleProperty().not());
importButton.setOnAction(event -> {
@@ -196,6 +197,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
HBox.setHgrow(region, Priority.SOMETIMES);
Button importMnemonicButton = new Button("Import");
importMnemonicButton.setDefaultButton(true);
importMnemonicButton.setOnAction(event -> {
showHideLink.setVisible(true);
setExpanded(false);
@@ -14,6 +14,7 @@ import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.UnitFormat;
@@ -61,6 +62,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
private final TextArea key;
private final ComboBox<ScriptType> keyScriptType;
private final CopyableLabel keyAddress;
private final CopyableLabel keyUtxos;
private final ComboBoxTextField toAddress;
private final ComboBox<Wallet> toWallet;
private final FeeRangeSlider feeRange;
@@ -72,14 +74,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Sweep Private Key");
Image image = new Image("image/seed.png", 50, 50, false, false);
if(!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
dialogPane.setGraphic(new WalletModelImage(WalletModel.SEED));
Form form = new Form();
Fieldset fieldset = new Fieldset();
@@ -136,6 +131,12 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
keyAddress.getStyleClass().add("fixed-width");
addressField.getInputs().add(keyAddress);
Field utxosField = new Field();
utxosField.setText("UTXOs:");
keyUtxos = new CopyableLabel();
utxosField.getInputs().add(keyUtxos);
Field toAddressField = new Field();
toAddressField.setText("Sweep to:");
toAddress = new ComboBoxTextField();
@@ -355,6 +356,8 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
Optional<Date> optSince = addressScanDateDialog.showAndWait();
if(optSince.isPresent()) {
since = optSince.get();
} else {
return;
}
}
@@ -369,7 +372,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 serviceProgressDialog = new ServiceProgressDialog("Address Scan", "Scanning address for transactions...", new DialogImage(DialogImage.Type.SPARROW), addressUtxosService);
serviceProgressDialog.initOwner(getDialogPane().getScene().getWindow());
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
}
@@ -1,6 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.*;
import com.google.common.base.Throwables;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.P2PKHAddress;
@@ -27,7 +27,6 @@ import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WebcamResolutionChangedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQRException;
@@ -39,14 +38,16 @@ import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.openpnp.capture.CaptureDevice;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -76,108 +77,141 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
private static final int SCAN_PERIOD_MILLIS = 100;
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA);
private final ObjectProperty<CaptureDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.HD);
private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0);
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
private final ObservableList<CaptureDevice> foundDevices = FXCollections.observableList(new ArrayList<>());
private final ObservableList<WebcamResolution> availableResolutions = FXCollections.observableList(new ArrayList<>());
private boolean postOpenUpdate;
public QRScanDialog() {
this.urDecoder = new URDecoder();
this.legacyUrDecoder = new LegacyURDecoder();
this.bbqrDecoder = new BBQRDecoder();
if(Config.get().isHdCapture()) {
webcamResolutionProperty.set(WebcamResolution.HD);
if(Config.get().getWebcamResolution() != null) {
webcamResolutionProperty.set(Config.get().getWebcamResolution());
}
this.webcamService = new WebcamService(webcamResolutionProperty.get(), null, new QRScanListener(), new ScanDelayCalculator());
this.webcamService = new WebcamService(webcamResolutionProperty.get(), null);
webcamService.setPeriod(Duration.millis(SCAN_PERIOD_MILLIS));
webcamService.setRestartOnFailure(false);
WebcamView webcamView = new WebcamView(webcamService, Config.get().isMirrorCapture());
final DialogPane dialogPane = new QRScanDialogPane();
setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
stackPane.getChildren().add(webcamView.getView());
Node wrappedView = Borders.wrap(stackPane).lineBorder().buildAll();
WebcamView webcamView = new WebcamView(webcamService, Config.get().isMirrorCapture());
ProgressBar progressBar = new ProgressBar();
progressBar.setMinHeight(20);
progressBar.setPadding(new Insets(0, 10, 0, 10));
progressBar.setPrefWidth(Integer.MAX_VALUE);
progressBar.progressProperty().bind(percentComplete);
webcamService.openingProperty().addListener((observable, oldValue, newValue) -> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(newValue ? 0.0 : -1.0));
}
Platform.runLater(() -> {
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
for(WebcamDevice device : WebcamScanDriver.getFoundDevices()) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
webcamDeviceProperty.set(device);
}
}
}
});
});
VBox vBox = new VBox(20);
StackPane stackPane = new StackPane();
stackPane.getChildren().add(webcamView.getView());
Node wrappedView = Borders.wrap(stackPane).lineBorder().buildAll();
vBox.getChildren().addAll(wrappedView, progressBar);
dialogPane.setContent(vBox);
webcamService.openingProperty().addListener((_, _, opening) -> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0));
}
if(opening) {
Platform.runLater(() -> {
try {
postOpenUpdate = true;
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getDevices());
newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !webcamService.getDevices().contains(device));
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
for(CaptureDevice device : foundDevices) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
webcamDeviceProperty.set(device);
}
}
}
updateList(availableResolutions, webcamService.getResolutions());
webcamResolutionProperty.set(webcamService.getResolution());
} finally {
postOpenUpdate = false;
}
});
}
});
webcamService.closedProperty().addListener((_, _, closed) -> {
if(closed && webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> {
if(!webcamService.isRunning()) {
webcamService.reset();
webcamService.start();
}
});
}
});
webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> {
Throwable exception = failedEvent.getSource().getException();
Throwable nested = exception;
while(nested.getCause() != null) {
nested = nested.getCause();
}
if(OsType.getCurrent() == OsType.WINDOWS &&
nested.getMessage().startsWith("Library 'OpenIMAJGrabber' was not loaded successfully from file")) {
exception = new WebcamDependencyException("Your system is missing a dependency required for the webcam. Follow the link below for more details.\n\n[https://sparrowwallet.com/docs/faq.html#your-system-is-missing-a-dependency-for-the-webcam]", exception);
} else if(nested.getMessage().startsWith("Cannot start native grabber") && Config.get().getWebcamDevice() != null) {
exception = new WebcamOpenException("Cannot open configured webcam " + Config.get().getWebcamDevice() + ", reverting to the default webcam");
Config.get().setWebcamDevice(null);
}
final Throwable result = exception;
Platform.runLater(() -> setResult(new Result(result)));
Throwable exception = Throwables.getRootCause(failedEvent.getSource().getException());
Platform.runLater(() -> setResult(new Result(exception)));
});
webcamService.start();
webcamResolutionProperty.addListener((observable, oldValue, newResolution) -> {
webcamResolutionProperty.addListener((_, oldResolution, newResolution) -> {
if(newResolution != null) {
setHeight(newResolution == WebcamResolution.HD ? (getHeight() - 100) : (getHeight() + 100));
EventManager.get().post(new WebcamResolutionChangedEvent(newResolution == WebcamResolution.HD));
if(newResolution.isStandardAspect() && oldResolution.isWidescreenAspect()) {
setWidth(getWidth());
setHeight(getHeight() + 100);
dialogPane.setMaxHeight(dialogPane.getPrefHeight() + 100);
dialogPane.setPrefHeight(dialogPane.getMaxHeight());
dialogPane.setMinHeight(dialogPane.getMaxHeight());
} else if(newResolution.isWidescreenAspect() && oldResolution.isStandardAspect()) {
setWidth(getWidth());
setHeight(getHeight() - 100);
dialogPane.setMaxHeight(dialogPane.getPrefHeight() - 100);
dialogPane.setPrefHeight(dialogPane.getMaxHeight());
dialogPane.setMinHeight(dialogPane.getMaxHeight());
}
EventManager.get().post(new WebcamResolutionChangedEvent(newResolution));
}
if(newResolution == null || !postOpenUpdate) {
webcamService.cancel();
}
webcamService.cancel();
});
webcamDeviceProperty.addListener((observable, oldValue, newValue) -> {
webcamDeviceProperty.addListener((_, _, newValue) -> {
Config.get().setWebcamDevice(newValue.getName());
if(!Objects.equals(webcamService.getDevice(), newValue)) {
webcamService.cancel();
}
});
setOnCloseRequest(event -> {
boolean isHdCapture = (webcamResolutionProperty.get() == WebcamResolution.HD);
if(Config.get().isHdCapture() != isHdCapture) {
Config.get().setHdCapture(isHdCapture);
setOnCloseRequest(_ -> {
if(webcamResolutionProperty.get() != null) {
Config.get().setWebcamResolution(webcamResolutionProperty.get());
}
Platform.runLater(() -> webcamResolutionProperty.set(null));
Platform.runLater(() -> {
webcamResolutionProperty.set(null);
webcamService.close();
});
});
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
final ButtonType camButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
final ButtonType deviceButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.LEFT);
final ButtonType resolutionButtonType = new javafx.scene.control.ButtonType("Resolution", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(deviceButtonType, resolutionButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
dialogPane.setPrefHeight(webcamResolutionProperty.get().isWidescreenAspect() ? 490 : 590);
dialogPane.setMinHeight(dialogPane.getPrefHeight());
AppServices.moveToActiveWindowScreen(this);
@@ -685,72 +719,32 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
}
private class QRScanListener implements WebcamListener {
@Override
public void webcamOpen(WebcamEvent webcamEvent) {
}
@Override
public void webcamClosed(WebcamEvent webcamEvent) {
if(webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> {
if(!webcamService.isRunning()) {
webcamService.reset();
webcamService.start();
}
});
}
}
@Override
public void webcamDisposed(WebcamEvent webcamEvent) {
}
@Override
public void webcamImageObtained(WebcamEvent webcamEvent) {
}
}
private class QRScanDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button = null;
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton hd = new ToggleButton(buttonType.getText());
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
hd.setGraphicTextGap(5);
setHdGraphic(hd, hd.isSelected());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(hd, buttonData);
hd.selectedProperty().addListener((observable, oldValue, newValue) -> {
webcamResolutionProperty.set(newValue ? WebcamResolution.HD : WebcamResolution.VGA);
setHdGraphic(hd, newValue);
});
button = hd;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<WebcamDevice> devicesCombo = new ComboBox<>(WebcamScanDriver.getFoundDevices());
ComboBox<CaptureDevice> devicesCombo = new ComboBox<>(foundDevices);
devicesCombo.setConverter(new StringConverter<>() {
@Override
public String toString(WebcamDevice device) {
return device instanceof WebcamScanDevice ? ((WebcamScanDevice)device).getDeviceName() : "Default Camera";
public String toString(CaptureDevice device) {
return device != null && device.getName() != null ? device.getName().replaceAll(" \\(.*\\)", "") : "Default Camera";
}
@Override
public WebcamDevice fromString(String string) {
public CaptureDevice fromString(String string) {
throw new UnsupportedOperationException();
}
});
devicesCombo.valueProperty().bindBidirectional(webcamDeviceProperty);
ButtonBar.setButtonData(devicesCombo, ButtonBar.ButtonData.LEFT);
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(devicesCombo, buttonData);
button = devicesCombo;
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.HELP_2) {
ComboBox<WebcamResolution> resolutionsCombo = new ComboBox<>(availableResolutions);
resolutionsCombo.valueProperty().bindBidirectional(webcamResolutionProperty);
ButtonBar.setButtonData(resolutionsCombo, ButtonBar.ButtonData.LEFT);
button = resolutionsCombo;
} else {
button = super.createButton(buttonType);
}
@@ -763,19 +757,39 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
button.disableProperty().bind(webcamService.openingProperty());
return button;
}
}
private void setHdGraphic(ToggleButton hd, boolean isHd) {
if(isHd) {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
public static <T extends Comparable<T>> void updateList(List<T> targetList, Collection<T> sourceList) {
List<T> sortedSource = new ArrayList<>(sourceList);
Collections.sort(sortedSource);
ListIterator<T> targetIter = targetList.listIterator();
int sourceIndex = 0;
while (sourceIndex < sortedSource.size() && targetIter.hasNext()) {
T sourceItem = sortedSource.get(sourceIndex);
T targetItem = targetIter.next();
int comparison = sourceItem.compareTo(targetItem);
if (comparison < 0) {
targetIter.previous(); // Back up to insert before
targetIter.add(sourceItem);
sourceIndex++;
} else if (comparison > 0) {
targetIter.remove();
} else {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.BAN));
sourceIndex++;
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
while (sourceIndex < sortedSource.size()) {
targetIter.add(sortedSource.get(sourceIndex));
sourceIndex++;
}
while (targetIter.hasNext()) {
targetIter.next();
targetIter.remove();
}
}
@@ -993,10 +1007,4 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
super(message, cause);
}
}
public static class ScanDelayCalculator implements WebcamUpdater.DelayCalculator {
public long calculateDelay(long snapshotDuration, double deviceFps) {
return Math.max(SCAN_PERIOD_MILLIS - snapshotDuration, 0L);
}
}
}
@@ -0,0 +1,166 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.BlockSummary;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import io.reactivex.Observable;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import javafx.animation.TranslateTransition;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static com.sparrowwallet.sparrow.control.BlockCube.CUBE_SIZE;
public class RecentBlocksView extends Pane {
private static final double CUBE_SPACING = 100;
private static final double ANIMATION_DURATION_MILLIS = 1000;
private static final double SEPARATOR_X = 74;
private final CompositeDisposable disposables = new CompositeDisposable();
private final ObjectProperty<List<BlockCube>> cubesProperty = new SimpleObjectProperty<>(new ArrayList<>());
private final Tooltip tooltip = new Tooltip();
public RecentBlocksView() {
cubesProperty.addListener((_, _, newValue) -> {
if(newValue != null && newValue.size() == 3) {
drawView();
}
});
Rectangle clip = new Rectangle(-20, -40, CUBE_SPACING * 3 - 20, 100);
setClip(clip);
Observable<Long> intervalObservable = Observable.interval(1, TimeUnit.MINUTES);
disposables.add(intervalObservable.observeOn(JavaFxScheduler.platform()).subscribe(_ -> {
for(BlockCube cube : getCubes()) {
cube.setElapsed(BlockCube.getElapsed(cube.getTimestamp()));
}
}));
updateFeeRatesSource(Config.get().getFeeRatesSource());
Tooltip.install(this, tooltip);
}
public void updateFeeRatesSource(FeeRatesSource feeRatesSource) {
tooltip.setText("Fee rate estimate from " + feeRatesSource.getDescription());
if(getCubes() != null && !getCubes().isEmpty()) {
getCubes().getFirst().setFeeRatesSource(feeRatesSource);
}
}
public void drawView() {
createSeparator();
for(int i = 0; i < 3; i++) {
BlockCube cube = getCubes().get(i);
cube.setTranslateX(i * CUBE_SPACING);
getChildren().add(cube);
}
}
private void createSeparator() {
Line separator = new Line(SEPARATOR_X, -9, SEPARATOR_X, CUBE_SIZE);
separator.getStyleClass().add("blocks-separator");
separator.getStrokeDashArray().addAll(5.0, 5.0); // Create dotted line pattern
separator.setStrokeWidth(1.0);
getChildren().add(separator);
}
public void update(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
List<BlockCube> cubes = new ArrayList<>();
cubes.add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
cubes.addAll(latestBlocks.stream().map(BlockCube::fromBlockSummary).limit(2).toList());
setCubes(cubes);
} else {
int knownTip = getCubes().stream().mapToInt(BlockCube::getHeight).max().orElse(0);
int latestTip = latestBlocks.stream().mapToInt(BlockSummary::getHeight).max().orElse(0);
if(latestTip > knownTip) {
addNewBlock(latestBlocks, currentFeeRate);
} else {
for(int i = 1; i < getCubes().size() && i <= latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i - 1);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(-1.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
updateFeeRate(currentFeeRate);
}
}
}
public void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
return;
}
for(int i = 0; i < getCubes().size() && i < latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(-1.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
}
public void add(BlockCube newCube) {
newCube.setTranslateX(-CUBE_SPACING);
getChildren().add(newCube);
getCubes().getFirst().setConfirmed(true);
getCubes().addFirst(newCube);
animateCubes();
if(getCubes().size() > 4) {
BlockCube lastCube = getCubes().getLast();
getChildren().remove(lastCube);
getCubes().remove(lastCube);
}
}
public void updateFeeRate(Double currentFeeRate) {
if(!getCubes().isEmpty()) {
BlockCube firstCube = getCubes().getFirst();
firstCube.setMedianFee(currentFeeRate);
}
}
private void animateCubes() {
for(int i = 0; i < getCubes().size(); i++) {
BlockCube cube = getCubes().get(i);
TranslateTransition transition = new TranslateTransition(Duration.millis(ANIMATION_DURATION_MILLIS), cube);
transition.setToX(i * CUBE_SPACING);
transition.play();
}
}
public List<BlockCube> getCubes() {
return cubesProperty.get();
}
public ObjectProperty<List<BlockCube>> cubesProperty() {
return cubesProperty;
}
public void setCubes(List<BlockCube> cubes) {
this.cubesProperty.set(cubes);
}
}
@@ -60,14 +60,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet " + walletForms.get(0).getMasterWallet().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);
}
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
VBox vBox = new VBox();
vBox.setSpacing(20);
@@ -42,8 +42,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
setTitle("Send to Many");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary.");
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
List<Payment> initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList());
Grid grid = getGrid(initialPayments);
@@ -3,13 +3,12 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.beans.property.*;
import javafx.concurrent.Worker;
import javafx.scene.Node;
import javafx.scene.control.DialogPane;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import org.controlsfx.dialog.ProgressDialog;
public class ServiceProgressDialog extends ProgressDialog {
public ServiceProgressDialog(String title, String header, String imagePath, Worker<?> worker) {
public ServiceProgressDialog(String title, String header, Node graphic, Worker<?> worker) {
super(worker);
final DialogPane dialogPane = getDialogPane();
@@ -20,8 +19,7 @@ public class ServiceProgressDialog extends ProgressDialog {
setHeaderText(header);
dialogPane.getStyleClass().remove("progress-dialog");
Image image = new Image(imagePath);
dialogPane.setGraphic(new ImageView(image));
dialogPane.setGraphic(graphic);
}
public static class ProxyWorker implements Worker<Boolean> {
@@ -44,8 +44,7 @@ public class TextAreaDialog extends Dialog<String> {
final DialogPane dialogPane = new TextAreaDialogPane();
setDialogPane(dialogPane);
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
HBox hbox = new HBox();
this.textArea = new TextArea(defaultValue);
@@ -29,8 +29,7 @@ public class TextfieldDialog extends Dialog<String> {
final DialogPane dialogPane = getDialogPane();
setDialogPane(dialogPane);
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
HBox hbox = new HBox();
this.textField = new TextField(defaultValue);
@@ -2,14 +2,13 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
@@ -23,17 +22,18 @@ public class TitledDescriptionPane extends TitledPane {
protected Hyperlink showHideLink;
protected HBox buttonBox;
public TitledDescriptionPane(String title, String description, String content, String imageUrl) {
public TitledDescriptionPane(String title, String description, String content, WalletModel walletModel) {
getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
getStyleClass().add("titled-description-pane");
setAccessibleText(title);
setPadding(Insets.EMPTY);
setGraphic(getTitle(title, description, imageUrl));
setGraphic(getTitle(title, description, walletModel));
setContent(getContentBox(content));
removeArrow();
}
protected Node getTitle(String title, String description, String imageUrl) {
protected Node getTitle(String title, String description, WalletModel walletModel) {
HBox listItem = new HBox();
listItem.setPadding(new Insets(10, 20, 10, 10));
listItem.setSpacing(10);
@@ -43,12 +43,8 @@ public class TitledDescriptionPane extends TitledPane {
imageBox.setMinHeight(50);
listItem.getChildren().add(imageBox);
Image image = new Image(imageUrl, 50, 50, true, true);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setImage(image);
imageBox.getChildren().add(imageView);
}
WalletModelImage walletModelImage = new WalletModelImage(walletModel);
imageBox.getChildren().add(walletModelImage);
VBox labelsBox = new VBox();
labelsBox.setSpacing(5);
@@ -3,19 +3,18 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@@ -39,10 +38,7 @@ import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.shape.CubicCurve;
import javafx.scene.shape.Line;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.*;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
@@ -231,6 +227,13 @@ public class TransactionDiagram extends GridPane {
getChildren().clear();
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
List<Payment> userPayments = getUserPayments();
if(!isFinal() && userPayments.size() > 1) {
Pane totalsPane = getTotalsPane(userPayments);
GridPane.setConstraints(totalsPane, 2, 0, 3, 1);
getChildren().add(totalsPane);
}
if(contextMenu == null) {
contextMenu = new ContextMenu();
MenuItem menuItem = new MenuItem("Save as Image...");
@@ -490,6 +493,11 @@ public class TransactionDiagram extends GridPane {
}
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
tooltip.setWrapText(true);
Window activeWindow = AppServices.getActiveWindow();
if(activeWindow != null) {
tooltip.setMaxWidth(activeWindow.getWidth());
}
if(!tooltip.getText().isEmpty()) {
label.setTooltip(tooltip);
}
@@ -613,6 +621,10 @@ public class TransactionDiagram extends GridPane {
}
}
private List<Payment> getUserPayments() {
return walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT || payment.getType() == Payment.Type.ANCHOR).toList();
}
private Pane getOutputsLines(List<Payment> displayedPayments) {
VBox pane = new VBox();
Group group = new Group();
@@ -673,7 +685,7 @@ public class TransactionDiagram extends GridPane {
List<OutputNode> outputNodes = new ArrayList<>();
for(Payment payment : displayedPayments) {
Glyph outputGlyph = GlyphUtils.getOutputGlyph(walletTx, payment);
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment;
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon", "anchor-icon").contains(style)) || payment instanceof AdditionalPayment || payment.getLabel() != null;
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
@@ -687,6 +699,11 @@ public class TransactionDiagram extends GridPane {
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
recipientTooltip.setWrapText(true);
Window activeWindow = AppServices.getActiveWindow();
if(activeWindow != null) {
recipientTooltip.setMaxWidth(activeWindow.getWidth());
}
recipientLabel.setTooltip(recipientTooltip);
HBox paymentBox = new HBox();
paymentBox.setAlignment(Pos.CENTER_LEFT);
@@ -702,7 +719,9 @@ public class TransactionDiagram extends GridPane {
paymentBox.getChildren().addAll(region, amountLabel);
}
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount()));
Wallet bip47Wallet = toWallet != null && toWallet.isBip47() ? toWallet : (toBip47Wallet != null && toBip47Wallet.isBip47() ? toBip47Wallet : null);
PaymentCode paymentCode = bip47Wallet == null ? null : bip47Wallet.getKeystores().getFirst().getExternalPaymentCode();
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode));
}
Set<Integer> seenIndexes = new HashSet<>();
@@ -766,7 +785,7 @@ public class TransactionDiagram extends GridPane {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount);
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode);
if(!outputNode.outputLabel.getChildren().isEmpty() && outputNode.outputLabel.getChildren().get(0) instanceof Label outputLabelControl) {
outputLabelControl.setContextMenu(contextMenu);
}
@@ -775,7 +794,7 @@ public class TransactionDiagram extends GridPane {
boolean highFee = (walletTx.getFeePercentage() > 0.1);
Label feeLabel = highFee ? new Label("High Fee", getFeeWarningGlyph()) : new Label("Fee", getFeeGlyph());
feeLabel.getStyleClass().addAll("output-label", "fee-label");
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
String percentage = walletTx.getFeePercentage() < 0.0001d ? "<0.01" : String.format("%.2f", walletTx.getFeePercentage() * 100.0);
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
feeTooltip.getStyleClass().add("fee-tooltip");
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
@@ -829,6 +848,33 @@ public class TransactionDiagram extends GridPane {
return txPane;
}
private Pane getTotalsPane(List<Payment> userPayments) {
VBox totalsBox = new VBox();
totalsBox.setPadding(new Insets(0, 0, 15, 0));
totalsBox.setAlignment(Pos.CENTER);
long amount = userPayments.stream().mapToLong(Payment::getAmount).sum();
HBox coinLabelBox = new HBox();
coinLabelBox.setAlignment(Pos.CENTER);
CoinLabel totalCoinLabel = new CoinLabel();
totalCoinLabel.setValue(amount);
coinLabelBox.getChildren().addAll(totalCoinLabel, new Label(" in "), new Label(Long.toString(userPayments.size())), new Label(" payments"));
totalsBox.getChildren().addAll(createSpacer(), coinLabelBox);
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
HBox fiatLabelBox = new HBox();
fiatLabelBox.setAlignment(Pos.CENTER);
FiatLabel fiatLabel = new FiatLabel();
fiatLabel.set(currencyRate, amount);
fiatLabelBox.getChildren().add(fiatLabel);
totalsBox.getChildren().add(fiatLabelBox);
}
return totalsBox;
}
private void saveAsImage() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -1073,16 +1119,26 @@ public class TransactionDiagram extends GridPane {
public Pane outputLabel;
public Address address;
public long amount;
public PaymentCode paymentCode;
public OutputNode(Pane outputLabel, Address address, long amount) {
this(outputLabel, address, amount, null);
}
public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode) {
this.outputLabel = outputLabel;
this.address = address;
this.amount = amount;
this.paymentCode = paymentCode;
}
}
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
this(address, value, null);
}
public LabelContextMenu(Address address, long value, PaymentCode paymentCode) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(event -> {
@@ -1119,6 +1175,17 @@ public class TransactionDiagram extends GridPane {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copySatsValue, copyBtcValue);
if(paymentCode != null) {
MenuItem copyPaymentCode = new MenuItem("Copy Payment Code");
copyPaymentCode.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(paymentCode.toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyPaymentCode);
}
}
}
}
@@ -227,7 +227,8 @@ public class TransactionDiagramLabel extends HBox {
}
Glyph glyph = GlyphUtils.getFeeGlyph();
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)";
String percentage = walletTx.getFeePercentage() < 0.0001d ? "<0.01" : String.format("%.2f", walletTx.getFeePercentage() * 100.0);
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)";
return getOutputLabel(glyph, text);
}
@@ -28,7 +28,8 @@ public class UsbStatusButton extends MenuButton {
for(Device device : devices) {
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.TREZOR_SAFE_3 ||
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
device.getModel() == WalletModel.TREZOR_SAFE_5 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02 ||
device.getModel() == WalletModel.ONEKEY_CLASSIC_1S || device.getModel() == WalletModel.ONEKEY_PRO)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
toggleItem.setOnAction(event -> {
@@ -18,8 +18,8 @@ import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> {
private Wallet wallet;
public WalletExportDialog(WalletForm walletForm) {
this.wallet = walletForm.getWallet();
public WalletExportDialog(WalletForm selectedWalletForm, List<WalletForm> allWalletForms) {
this.wallet = selectedWalletForm.getWallet();
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -45,10 +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(), new WalletTransactions(walletForm));
exporters = List.of(new Electrum(), new ElectrumPersonalServer(), new Descriptor(), new SpecterDesktop(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
} 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(), new WalletTransactions(walletForm));
new Descriptor(), new JadeMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow(), new WalletLabels(allWalletForms), new WalletTransactions(selectedWalletForm));
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -74,33 +74,15 @@ public class WalletIcon extends StackPane {
SVGImage svgImage;
if(Config.get().getTheme() == Theme.DARK) {
svgImage = loadSVGImage("/image/" + walletModel.getType() + "-icon-invert.svg");
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + "-icon-invert.svg");
} else {
svgImage = loadSVGImage("/image/" + walletModel.getType() + "-icon.svg");
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + "-icon.svg");
}
if(svgImage != null) {
getChildren().add(svgImage);
return;
}
Image image = null;
if(Config.get().getTheme() == Theme.DARK) {
image = loadImage("image/" + walletModel.getType() + "-icon-invert.png");
}
if(image == null) {
image = loadImage("image/" + walletModel.getType() + "-icon.png");
}
if(image == null) {
image = loadImage("image/" + walletModel.getType() + ".png");
}
if(image != null && !image.isError()) {
ImageView imageView = new ImageView(image);
getChildren().add(imageView);
}
}
}
@@ -127,16 +109,6 @@ public class WalletIcon extends StackPane {
return null;
}
private Image loadImage(String imageName) {
try {
return new Image(imageName, 15, 15, true, true);
} catch(Exception e) {
//ignore
}
return null;
}
private void addWalletIcon(String walletId) {
Image image = new Image(PROTOCOL + ":" + walletId.replaceAll(" ", "%20").replaceAll("#", "%23") + "?" + QUERY, WIDTH, HEIGHT, true, false);
getChildren().clear();
@@ -0,0 +1,78 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.NamedArg;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
public class WalletModelImage extends StackPane {
private static final Logger log = LoggerFactory.getLogger(WalletModelImage.class);
public static final int WIDTH = 50;
public static final int HEIGHT = 50;
private final ObjectProperty<WalletModel> walletModelProperty = new SimpleObjectProperty<>();
public WalletModelImage() {
setPrefSize(WIDTH, HEIGHT);
walletModelProperty.addListener((observable, oldValue, walletModel) -> {
refresh(walletModel);
});
}
public WalletModelImage(@NamedArg("walletModel") WalletModel walletModel) {
this();
walletModelProperty.set(walletModel);
}
public WalletModel getWalletModel() {
return walletModelProperty.get();
}
public ObjectProperty<WalletModel> walletModelProperty() {
return walletModelProperty;
}
public void refresh() {
WalletModel walletModel = getWalletModel();
refresh(walletModel);
}
protected void refresh(WalletModel walletModel) {
SVGImage svgImage;
if(Config.get().getTheme() == Theme.DARK) {
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + "-invert.svg");
} else {
svgImage = loadSVGImage("/image/walletmodel/" + walletModel.getType() + ".svg");
}
if(svgImage != null) {
getChildren().clear();
getChildren().add(svgImage);
}
}
private SVGImage loadSVGImage(String imageName) {
try {
URL url = AppServices.class.getResource(imageName);
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
log.error("Could not find image " + imageName);
}
return null;
}
}
@@ -17,6 +17,7 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
@@ -43,14 +44,7 @@ public class WalletSummaryDialog extends Dialog<Void> {
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Wallet Summary for " + (allOpenWallets ? "All Open Wallets" : masterWallets.get(0).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);
}
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
HBox hBox = new HBox(40);
@@ -110,6 +104,7 @@ public class WalletSummaryDialog extends Dialog<Void> {
vBox.getChildren().add(table);
hBox.getChildren().add(vBox);
HBox.setHgrow(vBox, Priority.ALWAYS);
Wallet balanceWallet;
if(allOpenWallets) {
@@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.OsType;
public enum WebcamPixelFormat {
//Only V4L2 formats defined in linux/videodev2.h are required here, declared in order of priority for supported formats
PIX_FMT_RGB24("RGB3", true),
PIX_FMT_YUYV("YUYV", true),
PIX_FMT_MJPG("MJPG", true),
PIX_FMT_NV12("NV12", false);
private final String name;
private final boolean supported;
WebcamPixelFormat(String name, boolean supported) {
this.name = name;
this.supported = supported;
}
public String getName() {
return name;
}
public boolean isSupported() {
return supported;
}
public String toString() {
return name;
}
public static WebcamPixelFormat fromFourCC(int fourCC) {
String strFourCC = fourCCToString(fourCC);
for(WebcamPixelFormat pixelFormat : WebcamPixelFormat.values()) {
if(pixelFormat.getName().equalsIgnoreCase(strFourCC)) {
return pixelFormat;
}
}
return null;
}
public static String fourCCToString(int fourCC) {
int fccVal = fourCC;
int tmp = fccVal;
if(OsType.getCurrent() == OsType.MACOS) {
tmp = ((tmp >> 16) & 0x0000FFFF) | ((tmp << 16) & 0xFFFF0000);
tmp = ((tmp & 0x00FF00FF) << 8) | ((tmp & 0xFF00FF00) >>> 8);
}
fccVal = tmp;
StringBuilder v = new StringBuilder(4);
for(int i = 0; i < 4; i++) {
char c = (char) (fccVal & 0xFF);
v.append(c);
fccVal >>>= 8;
}
return v.toString();
}
public static int getPriority(WebcamPixelFormat pixelFormat) {
if(pixelFormat == null) {
return values().length;
} else if(pixelFormat.isSupported()) {
return pixelFormat.ordinal();
} else {
return values().length + 1;
}
}
}
@@ -0,0 +1,71 @@
package com.sparrowwallet.sparrow.control;
import org.openpnp.capture.CaptureFormat;
import java.util.Arrays;
public enum WebcamResolution implements Comparable<WebcamResolution> {
VGA("480p", 640, 480),
HD("720p", 1280, 720),
FHD("1080p", 1920, 1080),
UHD4K("4K", 3840, 2160);
private final String name;
private final int width;
private final int height;
WebcamResolution(String name, int width, int height) {
this.name = name;
this.width = width;
this.height = height;
}
public int getPixelsCount() {
return this.width * this.height;
}
public boolean isStandardAspect() {
return Arrays.equals(getAspectRatio(), new int[]{4, 3});
}
public boolean isWidescreenAspect() {
return Arrays.equals(getAspectRatio(), new int[]{16, 9});
}
public int[] getAspectRatio() {
int factor = this.getCommonFactor(this.width, this.height);
int wr = this.width / factor;
int hr = this.height / factor;
return new int[] {wr, hr};
}
private int getCommonFactor(int width, int height) {
return height == 0 ? width : this.getCommonFactor(height, width % height);
}
public String getName() {
return name;
}
public int getWidth() {
return this.width;
}
public int getHeight() {
return this.height;
}
public String toString() {
return name;
}
public static WebcamResolution from(CaptureFormat captureFormat) {
for(WebcamResolution resolution : values()) {
if(captureFormat.getFormatInfo().width == resolution.width && captureFormat.getFormatInfo().height == resolution.height) {
return resolution;
}
}
return null;
}
}
@@ -1,372 +0,0 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.*;
import com.github.sarxos.webcam.ds.buildin.natives.Device;
import com.github.sarxos.webcam.ds.buildin.natives.DeviceList;
import com.github.sarxos.webcam.ds.buildin.natives.OpenIMAJGrabber;
import org.bridj.Pointer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.color.ColorSpace;
import java.awt.image.*;
import java.nio.ByteBuffer;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@SuppressWarnings("deprecation")
public class WebcamScanDevice implements WebcamDevice, WebcamDevice.BufferAccess, Runnable, WebcamDevice.FPSSource {
private static final Logger LOG = LoggerFactory.getLogger(WebcamScanDevice.class);
private static final int DEVICE_BUFFER_SIZE = 5;
private static final Dimension[] DIMENSIONS;
private static final int[] BAND_OFFSETS;
private static final int[] BITS;
private static final int[] OFFSET;
private static final int DATA_TYPE = 0;
private static final ColorSpace COLOR_SPACE;
public static final int SCAN_LOOP_WAIT_MILLIS = 100;
private int timeout = 5000;
private OpenIMAJGrabber grabber = null;
private Device device = null;
private Dimension size = null;
private ComponentSampleModel smodel = null;
private ColorModel cmodel = null;
private boolean failOnSizeMismatch = false;
private final AtomicBoolean disposed = new AtomicBoolean(false);
private final AtomicBoolean open = new AtomicBoolean(false);
private final AtomicBoolean fresh = new AtomicBoolean(false);
private Thread refresher = null;
private String name = null;
private String id = null;
private String fullname = null;
private long t1 = -1L;
private long t2 = -1L;
private volatile double fps = 0.0D;
protected WebcamScanDevice(Device device) {
this.device = device;
this.name = device.getNameStr();
this.id = device.getIdentifierStr();
this.fullname = String.format("%s %s", this.name, this.id);
}
public String getName() {
return this.fullname;
}
public String getDeviceName() {
return this.name;
}
public String getDeviceId() {
return this.id;
}
public Device getDeviceRef() {
return this.device;
}
public Dimension[] getResolutions() {
return DIMENSIONS;
}
public Dimension getResolution() {
if (this.size == null) {
this.size = this.getResolutions()[0];
}
return this.size;
}
public void setResolution(Dimension size) {
if (size == null) {
throw new IllegalArgumentException("Size cannot be null");
} else if (this.open.get()) {
throw new IllegalStateException("Cannot change resolution when webcam is open, please close it first");
} else {
this.size = size;
}
}
public ByteBuffer getImageBytes() {
if (this.disposed.get()) {
LOG.debug("Webcam is disposed, image will be null");
return null;
} else if (!this.open.get()) {
LOG.debug("Webcam is closed, image will be null");
return null;
} else {
if (this.fresh.compareAndSet(false, true)) {
this.updateFrameBuffer();
}
LOG.trace("Webcam grabber get image pointer");
Pointer<Byte> image = this.grabber.getImage();
this.fresh.set(false);
if (image == null) {
LOG.warn("Null array pointer found instead of image");
return null;
} else {
int length = this.size.width * this.size.height * 3;
LOG.trace("Webcam device get buffer, read {} bytes", length);
return image.getByteBuffer((long)length);
}
}
}
public void getImageBytes(ByteBuffer target) {
if (this.disposed.get()) {
LOG.debug("Webcam is disposed, image will be null");
} else if (!this.open.get()) {
LOG.debug("Webcam is closed, image will be null");
} else {
int minSize = this.size.width * this.size.height * 3;
int curSize = target.remaining();
if (minSize > curSize) {
throw new IllegalArgumentException(String.format("Not enough remaining space in target buffer (%d necessary vs %d remaining)", minSize, curSize));
} else {
if (this.fresh.compareAndSet(false, true)) {
this.updateFrameBuffer();
}
LOG.trace("Webcam grabber get image pointer");
Pointer<Byte> image = this.grabber.getImage();
this.fresh.set(false);
if (image == null) {
LOG.warn("Null array pointer found instead of image");
} else {
LOG.trace("Webcam device read buffer {} bytes", minSize);
image = image.validBytes((long)minSize);
image.getBytes(target);
}
}
}
}
public BufferedImage getImage() {
ByteBuffer buffer = this.getImageBytes();
if (buffer == null) {
LOG.error("Images bytes buffer is null!");
return null;
} else {
byte[] bytes = new byte[this.size.width * this.size.height * 3];
byte[][] data = new byte[][]{bytes};
buffer.get(bytes);
DataBufferByte dbuf = new DataBufferByte(data, bytes.length, OFFSET);
WritableRaster raster = Raster.createWritableRaster(this.smodel, dbuf, (Point)null);
BufferedImage bi = new BufferedImage(this.cmodel, raster, false, (Hashtable)null);
bi.flush();
return bi;
}
}
public void open() {
if (!this.disposed.get()) {
LOG.debug("Opening webcam device {}", this.getName());
if (this.size == null) {
this.size = this.getResolutions()[0];
}
if (this.size == null) {
throw new RuntimeException("The resolution size cannot be null");
} else {
LOG.debug("Webcam device {} starting session, size {}", this.device.getIdentifierStr(), this.size);
this.grabber = new OpenIMAJGrabber();
DeviceList list = (DeviceList)this.grabber.getVideoDevices().get();
Iterator var2 = list.asArrayList().iterator();
while(var2.hasNext()) {
Device d = (Device)var2.next();
d.getNameStr();
d.getIdentifierStr();
}
boolean started = this.grabber.startSession(this.size.width, this.size.height, 50, Pointer.pointerTo(this.device));
if (!started) {
throw new WebcamException("Cannot start native grabber!");
} else {
this.grabber.setTimeout(this.timeout);
LOG.debug("Webcam device session started");
Dimension size2 = new Dimension(this.grabber.getWidth(), this.grabber.getHeight());
int w1 = this.size.width;
int w2 = size2.width;
int h1 = this.size.height;
int h2 = size2.height;
if (w1 != w2 || h1 != h2) {
if (this.failOnSizeMismatch) {
throw new WebcamException(String.format("Different size obtained vs requested - [%dx%d] vs [%dx%d]", w1, h1, w2, h2));
}
Object[] args = new Object[]{w1, h1, w2, h2, w2, h2};
LOG.warn("Different size obtained vs requested - [{}x{}] vs [{}x{}]. Setting correct one. New size is [{}x{}]", args);
this.size = new Dimension(w2, h2);
}
this.smodel = new ComponentSampleModel(0, this.size.width, this.size.height, 3, this.size.width * 3, BAND_OFFSETS);
this.cmodel = new ComponentColorModel(COLOR_SPACE, BITS, false, false, 1, 0);
LOG.debug("Clear memory buffer");
this.clearMemoryBuffer();
LOG.debug("Webcam device {} is now open", this);
this.open.set(true);
this.refresher = this.startFramesRefresher();
}
}
}
}
private void clearMemoryBuffer() {
for(int i = 0; i < 5; ++i) {
this.grabber.nextFrame();
}
}
private Thread startFramesRefresher() {
Thread refresher = new Thread(this, String.format("frames-refresher-[%s]", this.id));
refresher.setUncaughtExceptionHandler(WebcamExceptionHandler.getInstance());
refresher.setDaemon(true);
refresher.start();
return refresher;
}
public void close() {
if (this.open.compareAndSet(true, false)) {
LOG.debug("Closing webcam device");
this.grabber.stopSession();
}
}
public void dispose() {
if (this.disposed.compareAndSet(false, true)) {
LOG.debug("Disposing webcam device {}", this.getName());
this.close();
}
}
public void setFailOnSizeMismatch(boolean fail) {
this.failOnSizeMismatch = fail;
}
public boolean isOpen() {
return this.open.get();
}
public int getTimeout() {
return this.timeout;
}
public void setTimeout(int timeout) {
if (this.isOpen()) {
throw new WebcamException("Timeout must be set before webcam is open");
} else {
this.timeout = timeout;
}
}
private void updateFrameBuffer() {
LOG.trace("Next frame");
if (this.t1 == -1L || this.t2 == -1L) {
this.t1 = System.currentTimeMillis();
this.t2 = System.currentTimeMillis();
}
int result = (new WebcamScanDevice.NextFrameTask(this)).nextFrame();
this.t1 = this.t2;
this.t2 = System.currentTimeMillis();
this.fps = (4.0D * this.fps + (double)(1000L / (this.t2 - this.t1 + 1L))) / 5.0D;
if (result == -1) {
LOG.error("Timeout when requesting image!");
} else if (result < -1) {
LOG.error("Error requesting new frame!");
}
}
public void run() {
do {
try {
Thread.sleep(SCAN_LOOP_WAIT_MILLIS);
} catch(InterruptedException e) {
//ignore
}
if (Thread.interrupted()) {
LOG.debug("Refresher has been interrupted");
return;
}
if (!this.open.get()) {
LOG.debug("Cancelling refresher");
return;
}
this.updateFrameBuffer();
} while(this.open.get());
}
public double getFPS() {
return this.fps;
}
@Override
public boolean equals(Object o) {
if(this == o) {
return true;
}
if(o == null || getClass() != o.getClass()) {
return false;
}
WebcamScanDevice that = (WebcamScanDevice) o;
return Objects.equals(fullname, that.fullname);
}
@Override
public int hashCode() {
return Objects.hash(fullname);
}
static {
DIMENSIONS = new Dimension[]{WebcamResolution.QQVGA.getSize(), WebcamResolution.QVGA.getSize(), WebcamResolution.VGA.getSize()};
BAND_OFFSETS = new int[]{0, 1, 2};
BITS = new int[]{8, 8, 8};
OFFSET = new int[]{0};
COLOR_SPACE = ColorSpace.getInstance(1000);
}
private class NextFrameTask extends WebcamTask {
private final AtomicInteger result = new AtomicInteger(0);
public NextFrameTask(WebcamDevice device) {
super(device);
}
public int nextFrame() {
try {
this.process();
} catch (InterruptedException var2) {
WebcamScanDevice.LOG.debug("Image buffer request interrupted", var2);
}
return this.result.get();
}
protected void handle() {
WebcamScanDevice device = (WebcamScanDevice)this.getDevice();
if (device.isOpen()) {
try {
Thread.sleep(SCAN_LOOP_WAIT_MILLIS);
} catch(InterruptedException e) {
//ignore
}
this.result.set(WebcamScanDevice.this.grabber.nextFrame());
WebcamScanDevice.this.fresh.set(true);
}
}
}
}
@@ -1,45 +0,0 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamDevice;
import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDevice;
import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDriver;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import java.util.ArrayList;
import java.util.List;
public class WebcamScanDriver extends WebcamDefaultDriver {
private static final ObservableList<WebcamDevice> webcamDevices = FXCollections.observableArrayList();
private static boolean rescan;
@Override
public List<WebcamDevice> getDevices() {
if(rescan || webcamDevices.isEmpty()) {
List<WebcamDevice> devices = super.getDevices();
List<WebcamDevice> scanDevices = new ArrayList<>();
for(WebcamDevice device : devices) {
WebcamDefaultDevice defaultDevice = (WebcamDefaultDevice)device;
WebcamScanDevice scanDevice = new WebcamScanDevice(defaultDevice.getDeviceRef());
if(scanDevices.stream().noneMatch(dev -> ((WebcamScanDevice)dev).getDeviceName().equals(scanDevice.getDeviceName()))) {
scanDevices.add(scanDevice);
}
}
List<WebcamDevice> newDevices = new ArrayList<>(scanDevices);
newDevices.removeAll(webcamDevices);
webcamDevices.addAll(newDevices);
webcamDevices.removeIf(device -> !scanDevices.contains(device));
}
return webcamDevices;
}
public static ObservableList<WebcamDevice> getFoundDevices() {
return webcamDevices;
}
public static void rescan() {
rescan = true;
}
}
@@ -1,11 +1,11 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.*;
import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import com.sparrowwallet.bokmakierie.Bokmakierie;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@@ -16,6 +16,8 @@ import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import net.sourceforge.zbar.ZBar;
import org.openpnp.capture.*;
import org.openpnp.capture.library.OpenpnpCaptureLibrary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,38 +25,76 @@ 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.*;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private List<CaptureDevice> devices;
private Set<WebcamResolution> resolutions;
private WebcamResolution resolution;
private WebcamDevice device;
private final WebcamListener listener;
private final WebcamUpdater.DelayCalculator delayCalculator;
private CaptureDevice device;
private final BooleanProperty opening = new SimpleBooleanProperty(false);
private final BooleanProperty closed = new SimpleBooleanProperty(false);
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
private static final int QR_SAMPLE_PERIOD_MILLIS = 200;
private Webcam cam;
private final OpenPnpCapture capture;
private CaptureStream stream;
private PropertyLimits zoomLimits;
private long lastQrSampleTime;
private final Reader qrReader;
private final Bokmakierie bokmakierie;
static {
Webcam.setDriver(new WebcamScanDriver());
if(log.isTraceEnabled()) {
OpenpnpCaptureLibrary.INSTANCE.Cap_setLogLevel(8);
} else if(log.isDebugEnabled()) {
OpenpnpCaptureLibrary.INSTANCE.Cap_setLogLevel(7);
} else if(log.isInfoEnabled()) {
OpenpnpCaptureLibrary.INSTANCE.Cap_setLogLevel(6);
}
OpenpnpCaptureLibrary.INSTANCE.Cap_installCustomLogFunction((level, ptr) -> {
switch(level) {
case 0:
case 1:
case 2:
case 3:
String err = ptr.getString(0).trim();
if(err.equals("tjDecompressHeader2 failed: No error") || err.matches("getPropertyLimits.*failed on.*")) { //Safe to ignore
log.debug(err);
} else {
log.error(err);
}
break;
case 4:
case 5:
case 6:
log.info(ptr.getString(0).trim());
break;
case 7:
log.debug(ptr.getString(0).trim());
break;
case 8:
default:
log.trace(ptr.getString(0).trim());
break;
}
});
}
public WebcamService(WebcamResolution resolution, WebcamDevice device, WebcamListener listener, WebcamUpdater.DelayCalculator delayCalculator) {
this.resolution = resolution;
this.device = device;
this.listener = listener;
this.delayCalculator = delayCalculator;
public WebcamService(WebcamResolution requestedResolution, CaptureDevice requestedDevice) {
this.capture = new OpenPnpCapture();
this.resolution = requestedResolution;
this.device = requestedDevice;
this.lastQrSampleTime = System.currentTimeMillis();
this.qrReader = new QRCodeReader();
this.bokmakierie = new Bokmakierie();
@@ -62,50 +102,84 @@ public class WebcamService extends ScheduledService<Image> {
@Override
public Task<Image> createTask() {
return new Task<Image>() {
return new Task<>() {
@Override
protected Image call() throws Exception {
try {
if(cam == null) {
List<Webcam> webcams = Webcam.getWebcams(1, TimeUnit.MINUTES);
if(webcams.isEmpty()) {
throw new UnsupportedOperationException("No camera available.");
if(stream == null) {
devices = capture.getDevices();
if(devices.isEmpty()) {
throw new UnsupportedOperationException("No cameras available");
}
cam = webcams.get(0);
CaptureDevice selectedDevice = devices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(devices.getFirst());
if(device != null) {
for(Webcam webcam : webcams) {
if(webcam.getDevice().getName().equals(device.getName())) {
cam = webcam;
for(CaptureDevice webcam : devices) {
if(webcam.getName().equals(device.getName())) {
selectedDevice = webcam;
}
}
} else if(Config.get().getWebcamDevice() != null) {
for(Webcam webcam : webcams) {
if(webcam.getDevice().getName().equals(Config.get().getWebcamDevice())) {
cam = webcam;
for(CaptureDevice webcam : devices) {
if(webcam.getName().equals(Config.get().getWebcamDevice())) {
selectedDevice = webcam;
}
}
}
device = cam.getDevice();
device = selectedDevice;
cam.setCustomViewSizes(resolution.getSize());
cam.setViewSize(resolution.getSize());
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
cam.addWebcamListener(listener);
if(device.getFormats().isEmpty()) {
throw new UnsupportedOperationException("No resolutions supported by camera " + device.getName());
}
List<CaptureFormat> deviceFormats = new ArrayList<>(device.getFormats());
//On *nix prioritise supported camera pixel formats, preferring RGB3, then YUYV, then MJPG
//On macOS and Windows, camera pixel format is largely abstracted away
if(OsType.getCurrent() == OsType.UNIX) {
deviceFormats.sort((f1, f2) -> {
WebcamPixelFormat pf1 = WebcamPixelFormat.fromFourCC(f1.getFormatInfo().fourcc);
WebcamPixelFormat pf2 = WebcamPixelFormat.fromFourCC(f2.getFormatInfo().fourcc);
return Integer.compare(WebcamPixelFormat.getPriority(pf1), WebcamPixelFormat.getPriority(pf2));
});
}
Map<WebcamResolution, CaptureFormat> supportedResolutions = deviceFormats.stream()
.filter(f -> WebcamResolution.from(f) != null)
.collect(Collectors.toMap(WebcamResolution::from, Function.identity(), (u, v) -> u, TreeMap::new));
resolutions = supportedResolutions.keySet();
CaptureFormat format = supportedResolutions.get(resolution);
if(format == null) {
if(!supportedResolutions.isEmpty()) {
resolution = getNearestEnum(resolution, supportedResolutions.keySet().toArray(new WebcamResolution[0]));
format = supportedResolutions.get(resolution);
} else {
format = device.getFormats().getFirst();
log.warn("Could not get standard capture resolution, using " + format.getFormatInfo().width + "x" + format.getFormatInfo().height);
}
}
if(log.isDebugEnabled()) {
log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")");
}
opening.set(true);
cam.open(true, delayCalculator);
stream = device.openStream(format);
opening.set(false);
closed.set(false);
try {
zoomLimits = stream.getPropertyLimits(CaptureProperty.Zoom);
} catch(Throwable e) {
log.debug("Error getting zoom limits on " + device + ", assuming no zoom function");
}
}
BufferedImage originalImage = cam.getImage();
if(originalImage == null) {
return null;
}
BufferedImage originalImage = stream.capture();
CroppedDimension cropped = getCroppedDimension(originalImage);
BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length);
BufferedImage framedImage = getFramedImage(originalImage, cropped);
@@ -128,19 +202,51 @@ public class WebcamService extends ScheduledService<Image> {
@Override
public void reset() {
cam = null;
stream = null;
zoomLimits = null;
super.reset();
}
@Override
public boolean cancel() {
if(cam != null && !cam.close()) {
cam.close();
if(stream != null) {
stream.close();
closed.set(true);
}
return super.cancel();
}
public void close() {
capture.close();
}
public PropertyLimits getZoomLimits() {
return zoomLimits;
}
public int getZoom() {
if(stream != null && zoomLimits != null) {
try {
return stream.getProperty(CaptureProperty.Zoom);
} catch(Exception e) {
log.error("Error getting zoom property on " + device, e);
}
}
return -1;
}
public void setZoom(int value) {
if(stream != null && zoomLimits != null) {
try {
stream.setProperty(CaptureProperty.Zoom, value);
} catch(Exception e) {
log.error("Error setting zoom property on " + device, e);
}
}
}
private void readQR(BufferedImage wideImage, BufferedImage croppedImage) {
Result result = readQR(wideImage);
if(result == null) {
@@ -189,7 +295,7 @@ public class WebcamService extends ScheduledService<Image> {
g2d.drawImage(image, 0, 0, null);
float[] dash1 = {10.0f};
g2d.setColor(Color.BLACK);
g2d.setStroke(new BasicStroke(resolution == WebcamResolution.HD ? 3.0f : 1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, dash1, 0.0f));
g2d.setStroke(new BasicStroke(resolution.isWidescreenAspect() ? 3.0f : 1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, dash1, 0.0f));
g2d.draw(new RoundRectangle2D.Double(cropped.x, cropped.y, cropped.length, cropped.length, 10, 10));
g2d.dispose();
return clone;
@@ -226,6 +332,14 @@ public class WebcamService extends ScheduledService<Image> {
}
}
public List<CaptureDevice> getDevices() {
return devices;
}
public Set<WebcamResolution> getResolutions() {
return resolutions;
}
public Result getResult() {
return resultProperty.get();
}
@@ -235,33 +349,48 @@ public class WebcamService extends ScheduledService<Image> {
}
public int getCamWidth() {
return resolution.getSize().width;
return resolution.getWidth();
}
public int getCamHeight() {
return resolution.getSize().height;
return resolution.getHeight();
}
public WebcamResolution getResolution() {
return resolution;
}
public void setResolution(WebcamResolution resolution) {
this.resolution = resolution;
}
public WebcamDevice getDevice() {
public CaptureDevice getDevice() {
return device;
}
public void setDevice(WebcamDevice device) {
public void setDevice(CaptureDevice device) {
this.device = device;
}
public boolean isOpening() {
return opening.get();
}
public BooleanProperty openingProperty() {
return opening;
}
public BooleanProperty closedProperty() {
return closed;
}
public static <T extends Enum<T>> T getNearestEnum(T target) {
return getNearestEnum(target, target.getDeclaringClass().getEnumConstants());
}
public static <T extends Enum<T>> T getNearestEnum(T target, T[] values) {
int ordinal = target.ordinal();
return Stream.concat(ordinal > 0 ? Stream.of(values[ordinal - 1]) : Stream.empty(), ordinal < values.length - 1 ? Stream.of(values[ordinal + 1]) : Stream.empty())
.findFirst()
.orElse(null);
}
private static class CroppedDimension {
public int x;
public int y;
@@ -46,6 +46,19 @@ public class WebcamView {
imageView.setOnContextMenuRequested(event -> {
contextMenu.show(imageView, event.getScreenX(), event.getScreenY());
});
imageView.setOnScroll(scrollEvent -> {
if(service.isRunning() && scrollEvent.getDeltaY() != 0 && service.getZoomLimits() != null) {
int currentZoom = service.getZoom();
if(currentZoom >= 0) {
int newZoom = scrollEvent.getDeltaY() > 0 ? Math.round(currentZoom * 1.1f) : Math.round(currentZoom * 0.9f);
newZoom = Math.max(newZoom, service.getZoomLimits().getMin());
newZoom = Math.min(newZoom, service.getZoomLimits().getMax());
if(newZoom != currentZoom) {
service.setZoom(newZoom);
}
}
}
});
service.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
@@ -57,27 +70,29 @@ public class WebcamView {
this.view = new Region() {
{
service.stateProperty().addListener((obs, oldState, newState) -> {
switch (newState) {
switch(newState) {
case READY:
if(imageProperty.get() == null) {
statusPlaceholder.setText("Initializing");
getChildren().setAll(statusPlaceholder);
}
break ;
break;
case SCHEDULED:
if(imageProperty.get() == null) {
statusPlaceholder.setText("Waiting");
getChildren().setAll(statusPlaceholder);
}
break ;
break;
case RUNNING:
imageView.imageProperty().unbind();
imageView.imageProperty().bind(imageProperty);
getChildren().setAll(imageView);
break ;
if(imageProperty.get() == null) {
imageView.imageProperty().unbind();
imageView.imageProperty().bind(imageProperty);
getChildren().setAll(imageView);
}
break;
case CANCELLED:
imageProperty.set(null);
imageView.imageProperty().unbind();
imageView.setImage(null);
statusPlaceholder.setText("Stopped");
getChildren().setAll(statusPlaceholder);
break;
@@ -93,7 +108,6 @@ public class WebcamView {
statusPlaceholder.setText("");
getChildren().clear();
}
requestLayout();
});
}
@@ -102,14 +116,14 @@ public class WebcamView {
super.layoutChildren();
double w = getWidth();
double h = getHeight();
if (service.isRunning()) {
if(service.isRunning()) {
imageView.setFitWidth(w);
imageView.setFitHeight(h);
imageView.resizeRelocate(0, 0, w, h);
} else {
double labelHeight = statusPlaceholder.prefHeight(w);
double labelWidth = statusPlaceholder.prefWidth(labelHeight);
statusPlaceholder.resizeRelocate((w - labelWidth)/2, (h-labelHeight)/2, labelWidth, labelHeight);
statusPlaceholder.resizeRelocate((w - labelWidth) / 2, (h - labelHeight) / 2, labelWidth, labelHeight);
}
}
@@ -36,7 +36,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
private ExtendedKey xprv;
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), importer.getWalletModel());
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
@@ -46,7 +46,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
}
public XprvKeystoreImportPane(Keystore keystore) {
super("Master Private Key", "BIP32 key", "", "image/" + WalletModel.SEED.getType() + ".png");
super("Master Private Key", "BIP32 key", "", WalletModel.SEED);
this.wallet = null;
this.importer = null;
this.defaultDerivation = keystore.getKeyDerivation();
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.BlockSummary;
import java.util.Map;
public class BlockSummaryEvent {
private final Map<Integer, BlockSummary> blockSummaryMap;
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap) {
this.blockSummaryMap = blockSummaryMap;
}
public Map<Integer, BlockSummary> getBlockSummaryMap() {
return blockSummaryMap;
}
}
@@ -1,13 +1,15 @@
package com.sparrowwallet.sparrow.event;
public class WebcamResolutionChangedEvent {
private final boolean hdResolution;
import com.sparrowwallet.sparrow.control.WebcamResolution;
public WebcamResolutionChangedEvent(boolean hdResolution) {
this.hdResolution = hdResolution;
public class WebcamResolutionChangedEvent {
private final WebcamResolution resolution;
public WebcamResolutionChangedEvent(WebcamResolution resolution) {
this.resolution = resolution;
}
public boolean isHdResolution() {
return hdResolution;
public WebcamResolution getResolution() {
return resolution;
}
}
@@ -16,6 +16,7 @@ public class FontAwesome5 extends GlyphFont {
*/
public static enum Glyph implements INamedCharacter {
ADJUST('\uf042'),
ANCHOR('\uf13d'),
ARROW_CIRCLE_DOWN('\uf0ab'),
ANGLE_DOUBLE_RIGHT('\uf101'),
ARROW_DOWN('\uf063'),
@@ -13,6 +13,8 @@ public class GlyphUtils {
return getMixGlyph();
} else if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
return getFakeMixGlyph();
} else if(payment.getType().equals(Payment.Type.ANCHOR)) {
return getAnchorGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
return getConsolidationGlyph();
} else if(walletTx.isPremixSend(payment)) {
@@ -217,4 +219,11 @@ public class GlyphUtils {
downGlyph.setFontSize(12);
return downGlyph;
}
public static Glyph getAnchorGlyph() {
Glyph anchorGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ANCHOR);
anchorGlyph.getStyleClass().add("anchor-icon");
anchorGlyph.setFontSize(12);
return anchorGlyph;
}
}
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.control.QRDensity;
import com.sparrowwallet.sparrow.control.WebcamResolution;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
@@ -56,7 +57,7 @@ public class Config {
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private QRDensity qrDensity;
private Boolean hdCapture;
private WebcamResolution webcamResolution;
private boolean mirrorCapture = true;
private boolean useZbar = true;
private String webcamDevice;
@@ -383,16 +384,12 @@ public class Config {
flush();
}
public Boolean getHdCapture() {
return hdCapture;
public WebcamResolution getWebcamResolution() {
return webcamResolution;
}
public Boolean isHdCapture() {
return hdCapture != null && hdCapture;
}
public void setHdCapture(Boolean hdCapture) {
this.hdCapture = hdCapture;
public void setWebcamResolution(WebcamResolution webcamResolution) {
this.webcamResolution = webcamResolution;
flush();
}
@@ -1,9 +1,12 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.wallet.KeystoreController;
import java.io.*;
import java.nio.charset.StandardCharsets;
@@ -92,7 +95,7 @@ public class Descriptor implements WalletImport, WalletExport {
InputStream secondClone = new ByteArrayInputStream(baos.toByteArray());
try {
return PdfUtils.getOutputDescriptor(firstClone).toWallet();
return ensureKeyDerivations(PdfUtils.getOutputDescriptor(firstClone).toWallet());
} catch(Exception e) {
//ignore
}
@@ -100,7 +103,7 @@ public class Descriptor implements WalletImport, WalletExport {
List<String> paragraphs = getParagraphs(secondClone);
for(String paragraph : paragraphs) {
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(paragraph);
return descriptor.toWallet();
return ensureKeyDerivations(descriptor.toWallet());
}
throw new ImportException("Could not find an output descriptor in the file");
@@ -116,7 +119,7 @@ public class Descriptor implements WalletImport, WalletExport {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
for(String line : reader.lines().map(String::trim).toArray(String[]::new)) {
if(line.isEmpty()) {
if(paragraph.length() > 0) {
if(!paragraph.isEmpty()) {
paragraphs.add(paragraph.toString());
paragraph.setLength(0);
}
@@ -127,13 +130,23 @@ public class Descriptor implements WalletImport, WalletExport {
}
}
if(paragraph.length() > 0) {
if(!paragraph.isEmpty()) {
paragraphs.add(paragraph.toString());
}
return paragraphs;
}
private static Wallet ensureKeyDerivations(Wallet wallet) {
for(Keystore keystore : wallet.getKeystores()) {
if(keystore.getKeyDerivation().getMasterFingerprint() == null || keystore.getKeyDerivation().getDerivationPath() == null) {
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, wallet.getScriptType().getDefaultDerivationPath()));
}
}
return wallet;
}
@Override
public boolean isWalletImportScannable() {
return true;
@@ -1,5 +0,0 @@
package com.sparrowwallet.sparrow.io;
public enum FileType {
TEXT, JSON, BINARY, UNKNOWN;
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.IOUtils;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.protocol.ScriptType;
@@ -56,7 +57,7 @@ public class Hwi {
return lark.enumerate().stream().map(Device::fromHardwareClient).toList();
} catch(Throwable e) {
log.error("Error enumerating USB devices", e);
throw new ImportException("Error scanning" + (e.getMessage() == null || e.getMessage().isEmpty() ? ", check devices are ready" : ": " + e.getMessage()), e);
throw new ImportException(e.getMessage() == null || e.getMessage().isEmpty() ? "Error scanning, check devices are ready" : e.getMessage(), e);
} finally {
isPromptActive = false;
}
@@ -1,153 +0,0 @@
package com.sparrowwallet.sparrow.io;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class IOUtils {
private static final Logger log = LoggerFactory.getLogger(IOUtils.class);
public static FileType getFileType(File file) {
try {
String type = Files.probeContentType(file.toPath());
if(type == null) {
if(file.getName().toLowerCase(Locale.ROOT).endsWith("txn") || file.getName().toLowerCase(Locale.ROOT).endsWith("psbt")) {
return FileType.TEXT;
}
if(file.exists()) {
try(BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line = br.readLine();
if(line != null) {
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
}
}
}
}
return FileType.BINARY;
} else if (type.equals("application/json")) {
return FileType.JSON;
} else if (type.startsWith("text")) {
return FileType.TEXT;
}
} catch (IOException e) {
//ignore
}
return FileType.UNKNOWN;
}
/**
* List directory contents for a resource folder. Not recursive.
* This is basically a brute-force implementation.
* Works for regular files, JARs and Java modules.
*
* @param clazz Any java class that lives in the same place as the resources you want.
* @param path Should end with "/", but not start with one.
* @return Just the name of each member item, not the full paths.
* @throws URISyntaxException
* @throws IOException
*/
public static String[] getResourceListing(Class clazz, String path) throws URISyntaxException, IOException {
URL dirURL = clazz.getClassLoader().getResource(path);
if(dirURL != null && dirURL.getProtocol().equals("file")) {
/* A file path: easy enough */
return new File(dirURL.toURI()).list();
}
if(dirURL == null) {
/*
* In case of a jar file, we can't actually find a directory.
* Have to assume the same jar as clazz.
*/
String me = clazz.getName().replace(".", "/")+".class";
dirURL = clazz.getClassLoader().getResource(me);
}
if(dirURL.getProtocol().equals("jar")) {
/* A JAR path */
String jarPath = dirURL.getPath().substring(5, dirURL.getPath().indexOf("!")); //strip out only the JAR file
JarFile jar = new JarFile(URLDecoder.decode(jarPath, "UTF-8"));
Enumeration<JarEntry> entries = jar.entries(); //gives ALL entries in jar
Set<String> result = new HashSet<String>(); //avoid duplicates in case it is a subdirectory
while(entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if(name.startsWith(path)) { //filter according to the path
String entry = name.substring(path.length());
int checkSubdir = entry.indexOf("/");
if (checkSubdir >= 0) {
// if it is a subdirectory, we just return the directory name
entry = entry.substring(0, checkSubdir);
}
if(!entry.isEmpty()) {
result.add(entry);
}
}
}
return result.toArray(new String[result.size()]);
}
if(dirURL.getProtocol().equals("jrt")) {
java.nio.file.FileSystem jrtFs = FileSystems.newFileSystem(URI.create("jrt:/"), Collections.emptyMap());
Path resourcePath = jrtFs.getPath("modules/com.sparrowwallet.sparrow", path);
return Files.list(resourcePath).map(filePath -> filePath.getFileName().toString()).toArray(String[]::new);
}
throw new UnsupportedOperationException("Cannot list files for URL " + dirURL);
}
public static boolean deleteDirectory(File directory) {
try {
Files.walk(directory.toPath())
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch(IOException e) {
return false;
}
return true;
}
public static boolean secureDelete(File file) {
if(file.exists()) {
long length = file.length();
SecureRandom random = new SecureRandom();
byte[] data = new byte[1024*1024];
random.nextBytes(data);
try(RandomAccessFile raf = new RandomAccessFile(file, "rws")) {
raf.seek(0);
raf.getFilePointer();
int pos = 0;
while(pos < length) {
raf.write(data);
pos += data.length;
}
} catch(IOException e) {
log.warn("Error overwriting file for deletion: " + file.getName(), e);
}
return file.delete();
}
return false;
}
}
@@ -2,6 +2,8 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.FileType;
import com.sparrowwallet.drongo.IOUtils;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
@@ -1,33 +1,43 @@
package com.sparrowwallet.sparrow.io;
import com.csvreader.CsvReader;
import com.google.gson.Gson;
import com.google.gson.*;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.*;
import org.apache.commons.lang3.time.DateUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class WalletLabels implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(WalletLabels.class);
private static final long ONE_DAY = 24*60*60*1000L;
private final List<WalletForm> walletForms;
public WalletLabels() {
this.walletForms = Collections.emptyList();
}
public WalletLabels(List<WalletForm> walletForms) {
this.walletForms = walletForms;
}
@@ -50,8 +60,9 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
List<Label> labels = new ArrayList<>();
List<Wallet> allWallets = wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets();
for(Wallet exportWallet : allWallets) {
Map<Date, Double> fiatRates = getFiatRates(walletForms);
for(WalletForm exportWalletForm : walletForms) {
Wallet exportWallet = exportWalletForm.getWallet();
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet);
String origin = outputDescriptor.toString(true, false, false);
@@ -61,34 +72,43 @@ public class WalletLabels implements WalletImport, WalletExport {
}
}
for(BlockTransaction blkTx : exportWallet.getWalletTransactions().values()) {
if(blkTx.getLabel() != null && !blkTx.getLabel().isEmpty()) {
labels.add(new Label(Type.tx, blkTx.getHashAsString(), blkTx.getLabel(), origin, null));
Set<Sha256Hash> confirmingTxs = new HashSet<>();
WalletTransactionsEntry walletTransactionsEntry = exportWalletForm.getWalletTransactionsEntry();
for(Entry entry : walletTransactionsEntry.getChildren()) {
TransactionEntry txEntry = (TransactionEntry)entry;
BlockTransaction blkTx = txEntry.getBlockTransaction();
labels.add(new TransactionLabel(blkTx.getHashAsString(), blkTx.getLabel(), origin,
txEntry.isConfirming() ? null : blkTx.getHeight(), blkTx.getDate(),
getFee(walletTransactionsEntry.getWallet(), blkTx), txEntry.getValue(),
getFiatValue(blkTx.getDate(), Transaction.SATOSHIS_PER_BITCOIN, fiatRates)));
if(txEntry.isConfirming()) {
confirmingTxs.add(blkTx.getHash());
}
}
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, null));
}
labels.add(new AddressLabel(addressNode.getAddress().toString(), addressNode.getLabel(), origin, addressNode.getDerivationPath().substring(1),
addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo))
.filter(ref -> !confirmingTxs.contains(ref.getHash())).map(BlockTransactionHash::getHeight).toList()));
}
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, spendable));
} else if(!txo.isSpent()) {
labels.add(new Label(Type.output, txo.toString(), null, null, spendable));
}
for(Map.Entry<BlockTransactionHashIndex, WalletNode> txoEntry : exportWallet.getWalletTxos().entrySet()) {
BlockTransactionHashIndex txo = txoEntry.getKey();
WalletNode addressNode = txoEntry.getValue();
Boolean spendable = (txo.isSpent() ? null : txo.getStatus() != Status.FROZEN);
labels.add(new InputOutputLabel(Type.output, txo.toString(), txo.getLabel(), origin, spendable, addressNode.getDerivationPath().substring(1), txo.getValue(),
confirmingTxs.contains(txo.getHash()) ? null : txo.getHeight(), txo.getDate(), getFiatValue(txo, fiatRates)));
if(txo.isSpent() && txo.getSpentBy().getLabel() != null && !txo.getSpentBy().getLabel().isEmpty()) {
labels.add(new Label(Type.input, txo.getSpentBy().toString(), txo.getSpentBy().getLabel(), null, null));
if(txo.isSpent()) {
BlockTransactionHashIndex txi = txo.getSpentBy();
labels.add(new InputOutputLabel(Type.input, txi.toString(), txi.getLabel(), origin, null, addressNode.getDerivationPath().substring(1), txi.getValue(),
confirmingTxs.contains(txi.getHash()) ? null : txi.getHeight(), txi.getDate(), getFiatValue(txi, fiatRates)));
}
}
}
try {
Gson gson = new Gson();
Gson gson = new GsonBuilder().registerTypeAdapter(Date.class, new GsonUTCDateAdapter()).create();
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
for(Label label : labels) {
@@ -114,7 +134,7 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public boolean isWalletExportScannable() {
return false;
return true;
}
@Override
@@ -185,7 +205,7 @@ public class WalletLabels implements WalletImport, WalletExport {
}
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet);
String origin = outputDescriptor.toString(true, false, false);
Origin origin = Origin.fromOutputDescriptor(outputDescriptor);
List<Entry> transactionEntries = walletForm.getWalletTransactionsEntry().getChildren();
List<Entry> addressEntries = new ArrayList<>();
@@ -194,7 +214,7 @@ public class WalletLabels implements WalletImport, WalletExport {
List<Entry> utxoEntries = walletForm.getWalletUtxosEntry().getChildren();
for(Label label : labels) {
if(label.origin != null && !label.origin.equals(origin)) {
if(label.origin != null && !Origin.fromString(label.origin).equals(origin)) {
continue;
}
@@ -247,11 +267,11 @@ public class WalletLabels implements WalletImport, WalletExport {
addChangedEntry(changedWalletEntries, txioEntry);
}
if(label.type == Type.output && !reference.isSpent()) {
if("false".equalsIgnoreCase(label.spendable) && reference.getStatus() != Status.FROZEN) {
if(label.type == Type.output && !reference.isSpent() && label.spendable != null) {
if(!label.spendable && reference.getStatus() != Status.FROZEN) {
reference.setStatus(Status.FROZEN);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
} else if("true".equalsIgnoreCase(label.spendable) && reference.getStatus() == Status.FROZEN) {
} else if(label.spendable && reference.getStatus() == Status.FROZEN) {
reference.setStatus(null);
addChangedUtxo(changedWalletUtxoStatuses, txioEntry);
}
@@ -316,7 +336,7 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public boolean isWalletImportScannable() {
return false;
return true;
}
@Override
@@ -324,12 +344,99 @@ public class WalletLabels implements WalletImport, WalletExport {
return true;
}
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 Map<Date, Double> getFiatRates(List<WalletForm> walletForms) {
ExchangeSource exchangeSource = getExchangeSource();
Currency fiatCurrency = getFiatCurrency();
Map<Date, Double> fiatRates = new HashMap<>();
if(fiatCurrency != null) {
long min = Long.MAX_VALUE;
long max = Long.MIN_VALUE;
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
if(!walletTransactionsEntry.getChildren().isEmpty()) {
LongSummaryStatistics stats = walletTransactionsEntry.getChildren().stream()
.map(entry -> ((TransactionEntry)entry).getBlockTransaction().getDate())
.filter(Objects::nonNull)
.collect(Collectors.summarizingLong(Date::getTime));
min = Math.min(min, stats.getMin());
max = Math.max(max, stats.getMax());
}
}
if(max > min) {
fiatRates = exchangeSource.getHistoricalExchangeRates(fiatCurrency, new Date(min - ONE_DAY), new Date(max));
}
}
return fiatRates;
}
private static ExchangeSource getExchangeSource() {
return Config.get().getExchangeSource() == null ? ExchangeSource.COINGECKO : Config.get().getExchangeSource();
}
private static Currency getFiatCurrency() {
return getExchangeSource() == ExchangeSource.NONE || !AppServices.onlineProperty().get() ? null : Config.get().getFiatCurrency();
}
private Map<Currency, BigDecimal> getFiatValue(TransactionEntry txEntry, Map<Date, Double> fiatRates) {
return getFiatValue(txEntry.getBlockTransaction().getDate(), txEntry.getValue(), fiatRates);
}
private Map<Currency, BigDecimal> getFiatValue(BlockTransactionHashIndex ref, Map<Date, Double> fiatRates) {
return getFiatValue(ref.getDate(), ref.getValue(), fiatRates);
}
private Map<Currency, BigDecimal> getFiatValue(Date date, long value, Map<Date, Double> fiatRates) {
Currency fiatCurrency = getFiatCurrency();
if(fiatCurrency != null) {
Double dayRate = null;
if(date == null) {
if(AppServices.getFiatCurrencyExchangeRate() != null) {
dayRate = AppServices.getFiatCurrencyExchangeRate().getBtcRate();
}
} else {
dayRate = fiatRates.get(DateUtils.truncate(date, Calendar.DAY_OF_MONTH));
}
if(dayRate != null) {
BigDecimal fiatValue = BigDecimal.valueOf(dayRate * value / Transaction.SATOSHIS_PER_BITCOIN);
return Map.of(fiatCurrency, fiatValue.setScale(fiatCurrency.getDefaultFractionDigits(), RoundingMode.HALF_UP));
}
}
return null;
}
private enum Type {
tx, addr, pubkey, input, output, xpub
}
private static class Label {
public Label(Type type, String ref, String label, String origin, String spendable) {
public Label(Type type, String ref, String label, String origin, Boolean spendable) {
this.type = type;
this.ref = ref;
this.label = label;
@@ -341,6 +448,119 @@ public class WalletLabels implements WalletImport, WalletExport {
String ref;
String label;
String origin;
String spendable;
Boolean spendable;
}
private static class TransactionLabel extends Label {
public TransactionLabel(String ref, String label, String origin, Integer height, Date time, Long fee, Long value, Map<Currency, BigDecimal> rate) {
super(Type.tx, ref, label, origin, null);
this.height = height;
this.time = time;
this.fee = fee;
this.value = value;
this.rate = rate;
}
Integer height;
Date time;
Long fee;
Long value;
Map<Currency, BigDecimal> rate;
}
private static class AddressLabel extends Label {
public AddressLabel(String ref, String label, String origin, String keypath, List<Integer> heights) {
super(Type.addr, ref, label, origin, null);
this.keypath = keypath;
this.heights = heights;
}
String keypath;
List<Integer> heights;
}
private static class InputOutputLabel extends Label {
public InputOutputLabel(Type type, String ref, String label, String origin, Boolean spendable, String keypath, Long value, Integer height, Date time, Map<Currency, BigDecimal> fmv) {
super(type, ref, label, origin, spendable);
this.keypath = keypath;
this.value = value;
this.height = height;
this.time = time;
this.fmv = fmv;
}
String keypath;
Long value;
Integer height;
Date time;
Map<Currency, BigDecimal> fmv;
}
public static class GsonUTCDateAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
private final DateFormat dateFormat;
public GsonUTCDateAdapter() {
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
}
@Override
public JsonElement serialize(Date src, java.lang.reflect.Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(dateFormat.format(src));
}
@Override
public Date deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
try {
return dateFormat.parse(json.getAsString());
} catch (ParseException e) {
throw new JsonParseException(e);
}
}
}
private static class Origin {
private static final Pattern KEY_ORIGIN_PATTERN = Pattern.compile("\\[([A-Fa-f0-9]{8})([/\\d'hH]+)?\\]");
private ScriptType scriptType;
private Set<KeyDerivation> keyDerivations;
@Override
public final boolean equals(Object o) {
if(this == o) {
return true;
}
if(!(o instanceof Origin origin)) {
return false;
}
return scriptType == origin.scriptType && keyDerivations.equals(origin.keyDerivations);
}
@Override
public int hashCode() {
int result = Objects.hashCode(scriptType);
result = 31 * result + keyDerivations.hashCode();
return result;
}
public static Origin fromOutputDescriptor(OutputDescriptor outputDescriptor) {
Origin origin = new Origin();
origin.scriptType = outputDescriptor.getScriptType();
origin.keyDerivations = new HashSet<>(outputDescriptor.getExtendedPublicKeysMap().values());
return origin;
}
public static Origin fromString(String strOrigin) {
Origin origin = new Origin();
origin.scriptType = ScriptType.fromDescriptor(strOrigin);
origin.keyDerivations = new HashSet<>();
Matcher keyOriginMatcher = KEY_ORIGIN_PATTERN.matcher(strOrigin);
while(keyOriginMatcher.find()) {
byte[] masterFingerprintBytes = keyOriginMatcher.group(1) != null ? Utils.hexToBytes(keyOriginMatcher.group(1)) : new byte[4];
origin.keyDerivations.add(new KeyDerivation(Utils.bytesToHex(masterFingerprintBytes), KeyDerivation.writePath(KeyDerivation.parsePath(keyOriginMatcher.group(2)))));
}
return origin;
}
}
}
@@ -93,7 +93,7 @@ public class WalletTransactions implements WalletExport {
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;
Long fee = getFee(wallet, txEntry.getBlockTransaction());
writer.write(fee == null ? "" : getCoinValue(bitcoinUnit, fee));
if(fiatCurrency != null) {
Double fiatValue = getFiatValue(txEntry, fiatRates);
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.io.db;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.IOUtils;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.keystoreimport;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -10,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.wallet.KeystoreController;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import org.controlsfx.tools.Borders;
@@ -20,16 +22,18 @@ import java.util.List;
public class KeystoreImportDialog extends Dialog<Keystore> {
private final KeystoreImportController keystoreImportController;
private Keystore keystore;
private final ScriptType scriptType;
private final String existingLabel;
public KeystoreImportDialog(Wallet wallet) {
this(wallet, KeystoreSource.HW_USB);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource) {
this(wallet, initialSource, null, null, false);
this(wallet, initialSource, null, null, Keystore.DEFAULT_LABEL, false);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation currentDerivation, WalletModel currentModel, boolean restrictImport) {
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation currentDerivation, WalletModel currentModel, String currentLabel, boolean restrictImport) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -37,6 +41,8 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
scriptType = wallet.getScriptType();
existingLabel = currentLabel;
try {
FXMLLoader ksiLoader = new FXMLLoader(AppServices.class.getResource("keystoreimport/keystoreimport.fxml"));
@@ -73,9 +79,10 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
private Keystore getWatchOnlyKeystore() {
this.keystore = new Keystore();
keystore.setLabel(existingLabel);
keystore.setSource(KeystoreSource.SW_WATCH);
keystore.setWalletModel(WalletModel.SPARROW);
keystore.setKeyDerivation(new KeyDerivation("",""));
keystore.setKeyDerivation(new KeyDerivation(KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT, scriptType.getDefaultDerivationPath()));
return keystore;
}
@@ -4,7 +4,6 @@ import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
@@ -151,6 +150,27 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
}
@Override
public Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes) {
PagedBatchRequestBuilder<String, Boolean> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(String.class).returnType(Boolean.class);
for(String scriptHash : scriptHashes) {
batchRequest.add(scriptHash, "blockchain.scripthash.unsubscribe", scriptHash);
}
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
log.info("Failed to unsubscribe from script hashes: " + e.getErrors().keySet(), e);
Map<String, Boolean> unsubscribedScriptHashes = scriptHashes.stream().collect(Collectors.toMap(s -> s, _ -> true));
unsubscribedScriptHashes.keySet().removeIf(scriptHash -> e.getErrors().containsKey(scriptHash));
return unsubscribedScriptHashes;
} catch(Exception e) {
log.info("Failed to unsubscribe from script hashes: " + scriptHashes, e);
return Collections.emptyMap();
}
}
@Override
@SuppressWarnings("unchecked")
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
@@ -170,6 +190,24 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
}
@Override
@SuppressWarnings("unchecked")
public Map<Integer, BlockStats> getBlockStats(Transport transport, Set<Integer> blockHeights) {
PagedBatchRequestBuilder<Integer, BlockStats> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(Integer.class).returnType(BlockStats.class);
for(Integer height : blockHeights) {
batchRequest.add(height, "blockchain.block.stats", height);
}
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
return (Map<Integer, BlockStats>)e.getSuccesses();
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to retrieve block stats for block heights: " + blockHeights, e);
}
}
@Override
@SuppressWarnings("unchecked")
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
@@ -1,12 +1,22 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Server;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.util.Locale;
public enum BlockExplorer {
MEMPOOL_SPACE("https://mempool.space"),
BLOCKSTREAM_INFO("https://blockstream.info"),
NONE("http://none");
private static final Logger log = LoggerFactory.getLogger(BlockExplorer.class);
private final Server server;
BlockExplorer(String url) {
@@ -16,4 +26,17 @@ public enum BlockExplorer {
public Server getServer() {
return server;
}
public static SVGImage getSVGImage(Server server) {
try {
URL url = AppServices.class.getResource("/image/blockexplorer/" + server.getHost().toLowerCase(Locale.ROOT) + "-icon.svg");
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
log.error("Could not load block explorer image for " + server.getHost());
}
return null;
}
}
@@ -2,14 +2,15 @@ package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
class BlockHeaderTip {
public class BlockHeaderTip {
public int height;
public String hex;
public BlockHeader getBlockHeader() {
if(hex == null) {
return null;
return new BlockHeader(0, Sha256Hash.ZERO_HASH, Sha256Hash.ZERO_HASH, Sha256Hash.ZERO_HASH, 0, 0, 0);
}
byte[] blockHeaderBytes = Utils.hexToBytes(hex);
@@ -0,0 +1,14 @@
package com.sparrowwallet.sparrow.net;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.sparrowwallet.sparrow.BlockSummary;
import java.util.Date;
@JsonIgnoreProperties(ignoreUnknown = true)
public record BlockStats(int height, String blockhash, double[] feerate_percentiles, int total_weight, int txs, long time) {
public BlockSummary toBlockSummary() {
Double medianFee = feerate_percentiles != null && feerate_percentiles.length > 0 ? feerate_percentiles[feerate_percentiles.length / 2] : null;
return new BlockSummary(height, new Date(time * 1000), medianFee, txs, total_weight);
}
}
@@ -12,6 +12,7 @@ import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.BlockSummary;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
@@ -26,17 +27,21 @@ import javafx.beans.property.SimpleIntegerProperty;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.util.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public class ElectrumServer {
private static final Logger log = LoggerFactory.getLogger(ElectrumServer.class);
@@ -57,15 +62,19 @@ public class ElectrumServer {
private static CloseableTransport transport;
private static final Map<String, List<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static final Map<String, List<String>> subscribedScriptHashes = new ConcurrentHashMap<>();
private static Server previousServer;
private static Map<String, String> retrievedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static final Map<String, String> retrievedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static Map<Sha256Hash, BlockTransaction> retrievedTransactions = Collections.synchronizedMap(new HashMap<>());
private static final Map<Sha256Hash, BlockTransaction> retrievedTransactions = new ConcurrentHashMap<>();
private static Set<String> sameHeightTxioScriptHashes = Collections.synchronizedSet(new HashSet<>());
private static final Map<Integer, BlockHeader> retrievedBlockHeaders = new ConcurrentHashMap<>();
private static final Map<Sha256Hash, BlockTransaction> broadcastedTransactions = new ConcurrentHashMap<>();
private static final Set<String> sameHeightTxioScriptHashes = ConcurrentHashMap.newKeySet();
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
@@ -73,6 +82,8 @@ public class ElectrumServer {
private static Server coreElectrumServer;
private static ServerCapability serverCapability;
private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed[:.][^\"]*)\".*");
private static synchronized CloseableTransport getTransport() throws ServerException {
@@ -113,12 +124,14 @@ public class ElectrumServer {
if(previousServer != null && !electrumServer.equals(previousServer)) {
retrievedScriptHashes.clear();
retrievedTransactions.clear();
retrievedBlockHeaders.clear();
TransactionHistoryService.walletLocks.values().forEach(walletLock -> walletLock.initialized = false);
}
previousServer = electrumServer;
HostAndPort hostAndPort = electrumServer.getHostAndPort();
boolean localNetworkAddress = !protocol.isOnionAddress(hostAndPort) && IpAddressMatcher.isLocalNetworkAddress(hostAndPort.getHost());
boolean localNetworkAddress = !Protocol.isOnionAddress(hostAndPort) && !PublicElectrumServer.isPublicServer(hostAndPort)
&& IpAddressMatcher.isLocalNetworkAddress(hostAndPort.getHost());
if(!localNetworkAddress && Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) {
HostAndPort proxy = HostAndPort.fromString(proxyServer);
@@ -220,6 +233,11 @@ public class ElectrumServer {
}
private static String getScriptHashStatus(String scriptHash, WalletNode walletNode) {
List<ScriptHashTx> scriptHashTxes = getScriptHashes(scriptHash, walletNode);
return getScriptHashStatus(scriptHashTxes);
}
private static List<ScriptHashTx> getScriptHashes(String scriptHash, WalletNode walletNode) {
List<BlockTransactionHashIndex> txos = new ArrayList<>(walletNode.getTransactionOutputs());
txos.addAll(walletNode.getTransactionOutputs().stream().filter(BlockTransactionHashIndex::isSpent).map(BlockTransactionHashIndex::getSpentBy).collect(Collectors.toList()));
Set<Sha256Hash> unique = new HashSet<>(txos.size());
@@ -242,10 +260,15 @@ public class ElectrumServer {
sameHeightTxioScriptHashes.add(scriptHash);
return 0;
});
if(!txos.isEmpty()) {
return txos.stream().map(txo -> new ScriptHashTx(txo.getHeight(), txo.getHashAsString(), txo.getFee() == null ? 0 : txo.getFee())).toList();
}
private static String getScriptHashStatus(List<ScriptHashTx> scriptHashTxes) {
if(!scriptHashTxes.isEmpty()) {
StringBuilder scriptHashStatus = new StringBuilder();
for(BlockTransactionHashIndex txo : txos) {
scriptHashStatus.append(txo.getHash().toString()).append(":").append(txo.getHeight()).append(":");
for(ScriptHashTx scriptHashTx : scriptHashTxes) {
scriptHashStatus.append(scriptHashTx.tx_hash).append(":").append(scriptHashTx.height).append(":");
}
return Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8)));
@@ -389,10 +412,12 @@ public class ElectrumServer {
public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
try {
Map<WalletNode, ScriptHashTx[]> nodeHashHistory = new LinkedHashMap<>(nodes.size());
Map<String, String> pathScriptHashes = new LinkedHashMap<>(nodes.size());
for(WalletNode node : nodes) {
if(node.getIndex() >= startIndex) {
pathScriptHashes.put(node.getDerivationPath(), getScriptHash(node));
nodeHashHistory.put(node, null);
}
}
@@ -400,43 +425,95 @@ public class ElectrumServer {
return;
}
//Even if we have some successes, failure to retrieve all references will result in an incomplete wallet history. Don't proceed if that's the case.
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), wallet, pathScriptHashes, true);
//Optimistic optimizations from guessing the script hash status based on known information
for(Map.Entry<WalletNode, ScriptHashTx[]> entry : nodeHashHistory.entrySet()) {
WalletNode node = entry.getKey();
String scriptHash = pathScriptHashes.get(node.getDerivationPath());
List<String> statuses = subscribedScriptHashes.get(scriptHash);
for(String path : result.keySet()) {
ScriptHashTx[] txes = result.get(path);
if(statuses != null && !statuses.isEmpty()) {
//Optimize for new transactions that have been recently broadcasted
for(Sha256Hash txid : broadcastedTransactions.keySet()) {
BlockTransaction blkTx = broadcastedTransactions.get(txid);
if(blkTx.getTransaction().getOutputs().stream().map(ElectrumServer::getScriptHash).anyMatch(scriptHash::equals) ||
blkTx.getTransaction().getInputs().stream().map(txInput -> getPrevOutput(wallet, txInput))
.filter(Objects::nonNull).map(ElectrumServer::getScriptHash).anyMatch(scriptHash::equals)) {
List<ScriptHashTx> scriptHashTxes = new ArrayList<>(getScriptHashes(scriptHash, node));
scriptHashTxes.add(new ScriptHashTx(0, txid.toString(), blkTx.getFee() == null ? 0 : blkTx.getFee()));
Optional<WalletNode> optionalNode = nodes.stream().filter(n -> n.getDerivationPath().equals(path)).findFirst();
if(optionalNode.isPresent()) {
WalletNode node = optionalNode.get();
String status = getScriptHashStatus(scriptHashTxes);
if(Objects.equals(status, statuses.getLast())) {
entry.setValue(scriptHashTxes.toArray(new ScriptHashTx[0]));
pathScriptHashes.remove(node.getDerivationPath());
}
}
}
//Some servers can return the same tx as multiple ScriptHashTx entries with different heights. Take the highest height only
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash)
.collect(TreeSet::new, (set, ref) -> {
Optional<BlockTransactionHash> optExisting = set.stream().filter(prev -> prev.getHash().equals(ref.getHash())).findFirst();
if(optExisting.isPresent()) {
if(optExisting.get().getHeight() < ref.getHeight()) {
set.remove(optExisting.get());
set.add(ref);
}
} else {
//Optimize for new confirmations should all pending transactions confirm at the current block height
if(entry.getValue() == null && AppServices.getCurrentBlockHeight() != null &&
node.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo))
.anyMatch(txo -> txo.getHeight() <= 0)) {
List<ScriptHashTx> scriptHashTxes = getScriptHashes(scriptHash, node);
for(ScriptHashTx scriptHashTx : scriptHashTxes) {
if(scriptHashTx.height <= 0) {
scriptHashTx.height = AppServices.getCurrentBlockHeight();
scriptHashTx.fee = 0;
}
}
String status = getScriptHashStatus(scriptHashTxes);
if(Objects.equals(status, statuses.getLast())) {
entry.setValue(scriptHashTxes.toArray(new ScriptHashTx[0]));
pathScriptHashes.remove(node.getDerivationPath());
}
}
}
}
if(!pathScriptHashes.isEmpty()) {
//Even if we have some successes, failure to retrieve all references will result in an incomplete wallet history. Don't proceed if that's the case.
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), wallet, pathScriptHashes, true);
for(String path : result.keySet()) {
ScriptHashTx[] txes = result.get(path);
Optional<WalletNode> optionalNode = nodes.stream().filter(n -> n.getDerivationPath().equals(path)).findFirst();
if(optionalNode.isPresent()) {
WalletNode node = optionalNode.get();
nodeHashHistory.put(node, txes);
}
}
}
for(WalletNode node : nodeHashHistory.keySet()) {
ScriptHashTx[] txes = nodeHashHistory.get(node);
//Some servers can return the same tx as multiple ScriptHashTx entries with different heights. Take the highest height only
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash)
.collect(TreeSet::new, (set, ref) -> {
Optional<BlockTransactionHash> optExisting = set.stream().filter(prev -> prev.getHash().equals(ref.getHash())).findFirst();
if(optExisting.isPresent()) {
if(optExisting.get().getHeight() < ref.getHeight()) {
set.remove(optExisting.get());
set.add(ref);
}
}, TreeSet::addAll);
Set<BlockTransactionHash> existingReferences = nodeTransactionMap.get(node);
} else {
set.add(ref);
}
}, TreeSet::addAll);
Set<BlockTransactionHash> existingReferences = nodeTransactionMap.get(node);
if(existingReferences == null) {
nodeTransactionMap.put(node, references);
} else {
for(BlockTransactionHash reference : references) {
if(!existingReferences.add(reference)) {
Optional<BlockTransactionHash> optionalReference = existingReferences.stream().filter(tr -> tr.getHash().equals(reference.getHash())).findFirst();
if(optionalReference.isPresent()) {
BlockTransactionHash existingReference = optionalReference.get();
if(existingReference.getHeight() < reference.getHeight()) {
existingReferences.remove(existingReference);
existingReferences.add(reference);
}
if(existingReferences == null) {
nodeTransactionMap.put(node, references);
} else {
for(BlockTransactionHash reference : references) {
if(!existingReferences.add(reference)) {
Optional<BlockTransactionHash> optionalReference = existingReferences.stream().filter(tr -> tr.getHash().equals(reference.getHash())).findFirst();
if(optionalReference.isPresent()) {
BlockTransactionHash existingReference = optionalReference.get();
if(existingReference.getHeight() < reference.getHeight()) {
existingReferences.remove(existingReference);
existingReferences.add(reference);
}
}
}
@@ -554,50 +631,66 @@ public class ElectrumServer {
}
public void getReferencedTransactions(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
Set<BlockTransactionHash> references = new TreeSet<>();
Map<BlockTransactionHash, Transaction> references = new TreeMap<>();
for(Set<BlockTransactionHash> nodeReferences : nodeTransactionMap.values()) {
references.addAll(nodeReferences);
for(BlockTransactionHash nodeReference : nodeReferences) {
references.put(nodeReference, null);
}
}
for(Iterator<BlockTransactionHash> iter = references.iterator(); iter.hasNext(); ) {
BlockTransactionHash reference = iter.next();
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
if(blockTransaction != null && reference.getHeight() == blockTransaction.getHeight()) {
iter.remove();
for(Iterator<Map.Entry<BlockTransactionHash, Transaction>> iter = references.entrySet().iterator(); iter.hasNext(); ) {
Map.Entry<BlockTransactionHash, Transaction> entry = iter.next();
BlockTransactionHash reference = entry.getKey();
BlockTransaction blockTransaction = wallet.getWalletTransaction(reference.getHash());
if(blockTransaction != null) {
if(reference.getHeight() == blockTransaction.getHeight()) {
iter.remove();
} else {
entry.setValue(blockTransaction.getTransaction());
}
} else if(broadcastedTransactions.containsKey(reference.getHash())) {
entry.setValue(broadcastedTransactions.get(reference.getHash()).getTransaction());
}
}
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
if(!references.isEmpty()) {
Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(wallet, references);
Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(wallet, references.keySet());
transactionMap = getTransactions(wallet, references, blockHeaderMap);
}
if(!transactionMap.equals(wallet.getTransactions())) {
wallet.updateTransactions(transactionMap);
broadcastedTransactions.keySet().removeAll(transactionMap.entrySet().stream().filter(entry -> entry.getValue().getHeight() > 0)
.map(Map.Entry::getKey).collect(Collectors.toSet()));
}
}
public Map<Integer, BlockHeader> getBlockHeaders(Wallet wallet, Set<BlockTransactionHash> references) throws ServerException {
try {
Map<Integer, BlockHeader> blockHeaderMap = new TreeMap<>();
Set<Integer> blockHeights = new TreeSet<>();
for(BlockTransactionHash reference : references) {
if(reference.getHeight() > 0) {
blockHeights.add(reference.getHeight());
if(retrievedBlockHeaders.containsKey(reference.getHeight())) {
blockHeaderMap.put(reference.getHeight(), retrievedBlockHeaders.get(reference.getHeight()));
} else {
blockHeights.add(reference.getHeight());
}
}
}
if(blockHeights.isEmpty()) {
return Collections.emptyMap();
return blockHeaderMap;
}
Map<Integer, String> result = electrumServerRpc.getBlockHeaders(getTransport(), wallet, blockHeights);
Map<Integer, BlockHeader> blockHeaderMap = new TreeMap<>();
for(Integer height : result.keySet()) {
byte[] blockHeaderBytes = Utils.hexToBytes(result.get(height));
BlockHeader blockHeader = new BlockHeader(blockHeaderBytes);
blockHeaderMap.put(height, blockHeader);
updateRetrievedBlockHeaders(height, blockHeader);
blockHeights.remove(height);
}
@@ -615,51 +708,61 @@ public class ElectrumServer {
}
}
public Map<Sha256Hash, BlockTransaction> getTransactions(Wallet wallet, Set<BlockTransactionHash> references, Map<Integer, BlockHeader> blockHeaderMap) throws ServerException {
public Map<Sha256Hash, BlockTransaction> getTransactions(Wallet wallet, Map<BlockTransactionHash, Transaction> references, Map<Integer, BlockHeader> blockHeaderMap) throws ServerException {
try {
Set<BlockTransactionHash> checkReferences = new TreeSet<>(references);
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
Set<BlockTransactionHash> checkReferences = new TreeSet<>(references.keySet());
Set<String> txids = new LinkedHashSet<>(references.size());
for(BlockTransactionHash reference : references) {
txids.add(reference.getHashAsString());
for(BlockTransactionHash reference : references.keySet()) {
if(references.get(reference) == null) {
txids.add(reference.getHashAsString());
}
}
Map<String, String> result = electrumServerRpc.getTransactions(getTransport(), wallet, txids);
if(!txids.isEmpty()) {
Map<String, String> result = electrumServerRpc.getTransactions(getTransport(), wallet, txids);
String strErrorTx = Sha256Hash.ZERO_HASH.toString();
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
for(String txid : result.keySet()) {
Sha256Hash hash = Sha256Hash.wrap(txid);
String strRawTx = result.get(txid);
String strErrorTx = Sha256Hash.ZERO_HASH.toString();
for(String txid : result.keySet()) {
Sha256Hash hash = Sha256Hash.wrap(txid);
String strRawTx = result.get(txid);
if(strRawTx.equals(strErrorTx)) {
transactionMap.put(hash, UNFETCHABLE_BLOCK_TRANSACTION);
checkReferences.removeIf(ref -> ref.getHash().equals(hash));
continue;
if(strRawTx.equals(strErrorTx)) {
transactionMap.put(hash, UNFETCHABLE_BLOCK_TRANSACTION);
checkReferences.removeIf(ref -> ref.getHash().equals(hash));
continue;
}
byte[] rawtx = Utils.hexToBytes(strRawTx);
Transaction transaction;
try {
transaction = new Transaction(rawtx);
} catch(ProtocolException e) {
log.error("Could not parse tx: " + strRawTx);
continue;
}
Optional<BlockTransactionHash> optionalReference = references.keySet().stream().filter(reference -> reference.getHash().equals(hash)).findFirst();
if(optionalReference.isEmpty()) {
throw new IllegalStateException("Returned transaction " + hash.toString() + " that was not requested");
}
BlockTransactionHash reference = optionalReference.get();
references.put(reference, transaction);
}
}
byte[] rawtx = Utils.hexToBytes(strRawTx);
Transaction transaction;
try {
transaction = new Transaction(rawtx);
} catch(ProtocolException e) {
log.error("Could not parse tx: " + strRawTx);
continue;
}
Optional<BlockTransactionHash> optionalReference = references.stream().filter(reference -> reference.getHash().equals(hash)).findFirst();
if(optionalReference.isEmpty()) {
throw new IllegalStateException("Returned transaction " + hash.toString() + " that was not requested");
}
BlockTransactionHash reference = optionalReference.get();
for(BlockTransactionHash reference : references.keySet()) {
Transaction transaction = references.get(reference);
Date blockDate = null;
if(reference.getHeight() > 0) {
BlockHeader blockHeader = blockHeaderMap.get(reference.getHeight());
if(blockHeader == null) {
transactionMap.put(hash, UNFETCHABLE_BLOCK_TRANSACTION);
checkReferences.removeIf(ref -> ref.getHash().equals(hash));
transactionMap.put(reference.getHash(), UNFETCHABLE_BLOCK_TRANSACTION);
checkReferences.removeIf(ref -> ref.getHash().equals(reference.getHash()));
continue;
}
blockDate = blockHeader.getTimeAsDate();
@@ -667,7 +770,7 @@ public class ElectrumServer {
BlockTransaction blockchainTransaction = new BlockTransaction(reference.getHash(), reference.getHeight(), blockDate, reference.getFee(), transaction);
transactionMap.put(hash, blockchainTransaction);
transactionMap.put(reference.getHash(), blockchainTransaction);
checkReferences.remove(reference);
}
@@ -875,6 +978,96 @@ public class ElectrumServer {
return Transaction.DEFAULT_MIN_RELAY_FEE;
}
public Map<Integer, BlockSummary> getRecentBlockSummaryMap() throws ServerException {
return getBlockSummaryMap(null, null);
}
public Map<Integer, BlockSummary> getBlockSummaryMap(Integer height, BlockHeader blockHeader) throws ServerException {
if(serverCapability.supportsBlockStats()) {
if(height == null) {
Integer current = AppServices.getCurrentBlockHeight();
if(current == null) {
return Collections.emptyMap();
}
Set<Integer> heights = IntStream.range(current - 1, current + 1).boxed().collect(Collectors.toSet());
Map<Integer, BlockStats> blockStats = electrumServerRpc.getBlockStats(getTransport(), heights);
return blockStats.keySet().stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> blockStats.get(v).toBlockSummary()));
} else {
Map<Integer, BlockStats> blockStats = electrumServerRpc.getBlockStats(getTransport(), Set.of(height));
return blockStats.keySet().stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> blockStats.get(v).toBlockSummary()));
}
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get())) {
try {
if(blockHeader == null) {
return feeRatesSource.getRecentBlockSummaries();
} else {
Map<Integer, BlockSummary> blockSummaryMap = new HashMap<>();
BlockSummary blockSummary = feeRatesSource.getBlockSummary(Sha256Hash.twiceOf(blockHeader.bitcoinSerialize()));
if(blockSummary != null && blockSummary.getHeight() != null) {
blockSummaryMap.put(blockSummary.getHeight(), blockSummary);
}
return blockSummaryMap;
}
} catch(Exception e) {
return getServerBlockSummaryMap(height, blockHeader);
}
} else {
return getServerBlockSummaryMap(height, blockHeader);
}
}
private Map<Integer, BlockSummary> getServerBlockSummaryMap(Integer height, BlockHeader blockHeader) throws ServerException {
if(blockHeader == null || height == null) {
Integer current = AppServices.getCurrentBlockHeight();
if(current == null) {
return Collections.emptyMap();
}
Set<BlockTransactionHash> references = IntStream.range(current - 1, current + 1)
.mapToObj(i -> new BlockTransaction(null, i, null, null, null)).collect(Collectors.toSet());
Map<Integer, BlockHeader> blockHeaders = getBlockHeaders(null, references);
return blockHeaders.keySet().stream()
.collect(Collectors.toMap(java.util.function.Function.identity(), v -> new BlockSummary(v, blockHeaders.get(v).getTimeAsDate())));
} else {
Map<Integer, BlockSummary> blockSummaryMap = new HashMap<>();
blockSummaryMap.put(height, new BlockSummary(height, blockHeader.getTimeAsDate()));
return blockSummaryMap;
}
}
public List<BlockTransaction> getRecentMempoolTransactions() {
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get())) {
try {
List<BlockTransactionHash> recentTransactions = feeRatesSource.getRecentMempoolTransactions();
Map<BlockTransactionHash, Transaction> setReferences = new HashMap<>();
setReferences.put(recentTransactions.getFirst(), null);
Map<Sha256Hash, BlockTransaction> transactions = getTransactions(null, setReferences, Collections.emptyMap());
return transactions.values().stream().filter(blxTx -> blxTx.getTransaction() != null).toList();
} catch(Exception e) {
return Collections.emptyList();
}
} else {
return Collections.emptyList();
}
}
public Sha256Hash broadcastTransaction(Transaction transaction, Long fee) throws ServerException {
Sha256Hash txid = broadcastTransactionPrivately(transaction);
if(txid != null) {
BlockTransaction blkTx = new BlockTransaction(txid, 0, null, fee, transaction);
broadcastedTransactions.put(txid, blkTx);
}
return txid;
}
public Sha256Hash broadcastTransactionPrivately(Transaction transaction) throws ServerException {
//If Tor proxy is configured, try all external broadcast sources in random order before falling back to connected Electrum server
if(AppServices.isUsingProxy()) {
@@ -987,6 +1180,14 @@ public class ElectrumServer {
return scriptHashes;
}
private static TransactionOutput getPrevOutput(Wallet wallet, TransactionInput txInput) {
try {
return wallet.getWalletTransaction(txInput.getOutpoint().getHash()).getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
} catch(Exception e) {
return null;
}
}
public static String getScriptHash(WalletNode node) {
byte[] hash = Sha256Hash.hash(node.getOutputScript().getProgram());
byte[] reversed = Utils.reverseBytes(hash);
@@ -1023,6 +1224,10 @@ public class ElectrumServer {
existingStatuses.add(status);
}
public static void updateRetrievedBlockHeaders(Integer blockHeight, BlockHeader blockHeader) {
retrievedBlockHeaders.put(blockHeight, blockHeader);
}
public static ServerCapability getServerCapability(List<String> serverVersion) {
if(!serverVersion.isEmpty()) {
String server = serverVersion.getFirst().toLowerCase(Locale.ROOT);
@@ -1031,7 +1236,7 @@ public class ElectrumServer {
}
if(server.startsWith("cormorant")) {
return new ServerCapability(true);
return new ServerCapability(true, false, true);
}
if(server.startsWith("electrs/")) {
@@ -1217,7 +1422,7 @@ public class ElectrumServer {
firstCall = false;
//If electrumx is detected, we can upgrade to batched RPC. Electrs/EPS do not support batching.
ServerCapability serverCapability = getServerCapability(serverVersion);
serverCapability = getServerCapability(serverVersion);
if(serverCapability.supportsBatching()) {
log.debug("Upgrading to batched JSON-RPC");
electrumServerRpc = new BatchedElectrumServerRpc(electrumServerRpc.getIdCounterValue(), serverCapability.getMaxTargetBlocks());
@@ -1509,6 +1714,7 @@ public class ElectrumServer {
private final Sha256Hash txId;
private final Set<WalletNode> nodes;
private final IntegerProperty iterationCount = new SimpleIntegerProperty(0);
private boolean cancelled;
public TransactionMempoolService(Wallet wallet, Sha256Hash txId, Set<WalletNode> nodes) {
this.wallet = wallet;
@@ -1524,6 +1730,22 @@ public class ElectrumServer {
return iterationCount;
}
public boolean isCancelled() {
return cancelled;
}
@Override
public void start() {
this.cancelled = false;
super.start();
}
@Override
public boolean cancel() {
this.cancelled = true;
return super.cancel();
}
@Override
protected Task<Set<String>> createTask() {
return new Task<>() {
@@ -1569,7 +1791,7 @@ public class ElectrumServer {
protected Map<Sha256Hash, BlockTransaction> call() throws ServerException {
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
for(Sha256Hash ref : references) {
if(retrievedTransactions.get(ref) != null) {
if(retrievedTransactions.containsKey(ref)) {
transactionMap.put(ref, retrievedTransactions.get(ref));
}
}
@@ -1629,15 +1851,17 @@ public class ElectrumServer {
ElectrumServer electrumServer = new ElectrumServer();
List<Set<BlockTransactionHash>> outputTransactionReferences = electrumServer.getOutputTransactionReferences(transaction, indexStart, indexEnd, blockTransactionHashes);
Set<BlockTransactionHash> setReferences = new HashSet<>();
Map<BlockTransactionHash, Transaction> setReferences = new HashMap<>();
for(Set<BlockTransactionHash> outputReferences : outputTransactionReferences) {
if(outputReferences != null) {
setReferences.addAll(outputReferences);
for(BlockTransactionHash outputReference : outputReferences) {
setReferences.put(outputReference, null);
}
}
}
setReferences.remove(null);
setReferences.remove(UNFETCHABLE_BLOCK_TRANSACTION);
setReferences.removeIf(ref -> transactionMap.get(ref.getHash()) != null);
setReferences.keySet().removeIf(ref -> transactionMap.get(ref.getHash()) != null);
List<BlockTransaction> blockTransactions = new ArrayList<>(transaction.getOutputs().size());
for(int i = 0; i < transaction.getOutputs().size(); i++) {
@@ -1645,7 +1869,7 @@ public class ElectrumServer {
}
if(!setReferences.isEmpty()) {
Map<Integer, BlockHeader> blockHeaderMap = electrumServer.getBlockHeaders(null, setReferences);
Map<Integer, BlockHeader> blockHeaderMap = electrumServer.getBlockHeaders(null, setReferences.keySet());
transactionMap.putAll(electrumServer.getTransactions(null, setReferences, blockHeaderMap));
}
@@ -1686,9 +1910,11 @@ public class ElectrumServer {
public static class BroadcastTransactionService extends Service<Sha256Hash> {
private final Transaction transaction;
private final Long fee;
public BroadcastTransactionService(Transaction transaction) {
public BroadcastTransactionService(Transaction transaction, Long fee) {
this.transaction = transaction;
this.fee = fee;
}
@Override
@@ -1696,7 +1922,7 @@ public class ElectrumServer {
return new Task<>() {
protected Sha256Hash call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
return electrumServer.broadcastTransactionPrivately(transaction);
return electrumServer.broadcastTransaction(transaction, fee);
}
};
}
@@ -1715,6 +1941,110 @@ public class ElectrumServer {
}
}
public static class BlockSummaryService extends Service<BlockSummaryEvent> {
private final List<NewBlockEvent> newBlockEvents;
public BlockSummaryService(List<NewBlockEvent> newBlockEvents) {
this.newBlockEvents = newBlockEvents;
}
@Override
protected Task<BlockSummaryEvent> createTask() {
return new Task<>() {
protected BlockSummaryEvent call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<Integer, BlockSummary> blockSummaryMap = new LinkedHashMap<>();
int maxHeight = AppServices.getBlockSummaries().keySet().stream().mapToInt(Integer::intValue).max().orElse(0);
int startHeight = newBlockEvents.stream().mapToInt(NewBlockEvent::getHeight).min().orElse(0);
int endHeight = newBlockEvents.stream().mapToInt(NewBlockEvent::getHeight).max().orElse(0);
int totalBlocks = Math.max(0, endHeight - maxHeight);
if(startHeight == 0 || totalBlocks > 1 || startHeight > maxHeight + 1) {
if(isBlockstorm(totalBlocks)) {
int start = Math.max(maxHeight + 1, endHeight - 15);
for(int height = start; height <= endHeight; height++) {
blockSummaryMap.put(height, new BlockSummary(height, new Date(), 1.0d, 0, 0));
}
} else {
blockSummaryMap.putAll(electrumServer.getRecentBlockSummaryMap());
}
}
List<NewBlockEvent> events = new ArrayList<>(newBlockEvents);
events.removeIf(event -> blockSummaryMap.containsKey(event.getHeight()));
if(!events.isEmpty()) {
for(NewBlockEvent event : newBlockEvents) {
blockSummaryMap.putAll(electrumServer.getBlockSummaryMap(event.getHeight(), event.getBlockHeader()));
}
}
Config config = Config.get();
if(!isBlockstorm(totalBlocks) && !AppServices.isUsingProxy() && config.getServer().getProtocol().equals(Protocol.SSL)
&& (config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER || config.getServerType() == ServerType.ELECTRUM_SERVER)) {
subscribeRecent(electrumServer);
}
return new BlockSummaryEvent(blockSummaryMap);
}
};
}
private boolean isBlockstorm(int totalBlocks) {
return Network.get() != Network.MAINNET && totalBlocks > 2;
}
private final static Set<String> subscribedRecent = Collections.newSetFromMap(new ConcurrentHashMap<>());
private void subscribeRecent(ElectrumServer electrumServer) {
Set<String> unsubscribeScriptHashes = new HashSet<>(subscribedRecent);
unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey);
electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes);
subscribedRecent.removeAll(unsubscribeScriptHashes);
Map<String, String> subscribeScriptHashes = new HashMap<>();
List<BlockTransaction> recentTransactions = electrumServer.getRecentMempoolTransactions();
for(BlockTransaction blkTx : recentTransactions) {
for(int i = 0; i < blkTx.getTransaction().getOutputs().size() && subscribeScriptHashes.size() < 10; i++) {
TransactionOutput txOutput = blkTx.getTransaction().getOutputs().get(i);
String scriptHash = getScriptHash(txOutput);
if(!subscribedScriptHashes.containsKey(scriptHash)) {
subscribeScriptHashes.put("m/" + i, getScriptHash(txOutput));
}
}
}
if(!subscribeScriptHashes.isEmpty()) {
try {
electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes);
subscribedRecent.addAll(subscribeScriptHashes.values());
} catch(ElectrumServerRpcException e) {
log.debug("Error subscribing to recent mempool transactions", e);
}
}
ScheduledService<Void> broadcastService = new ScheduledService<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
for(BlockTransaction blkTx : recentTransactions) {
electrumServer.broadcastTransaction(blkTx.getTransaction());
}
return null;
}
};
}
};
broadcastService.setDelay(Duration.seconds(Math.random() * 60 * 10));
broadcastService.setPeriod(Duration.hours(1));
broadcastService.setOnSucceeded(_ -> broadcastService.cancel());
broadcastService.setOnFailed(_ -> broadcastService.cancel());
broadcastService.start();
}
}
public static class WalletDiscoveryService extends Service<Optional<Wallet>> {
private final List<Wallet> wallets;
@@ -22,8 +22,12 @@ public interface ElectrumServerRpc {
Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes);
Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes);
Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights);
Map<Integer, BlockStats> getBlockStats(Transport transport, Set<Integer> blockHeights);
Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids);
Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash);
@@ -9,9 +9,12 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.apache.commons.lang3.time.DateUtils;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.Instant;
@@ -21,7 +24,7 @@ import java.util.*;
import java.util.stream.Collectors;
public enum ExchangeSource {
NONE("None") {
NONE("None", null) {
@Override
public List<Currency> getSupportedCurrencies() {
return Collections.emptyList();
@@ -37,7 +40,7 @@ public enum ExchangeSource {
return Collections.emptyMap();
}
},
COINBASE("Coinbase") {
COINBASE("Coinbase", "No historical rates") {
@Override
public List<Currency> getSupportedCurrencies() {
return getRates().data.rates.keySet().stream().filter(code -> isValidISO4217Code(code.toUpperCase(Locale.ROOT)))
@@ -121,7 +124,7 @@ public enum ExchangeSource {
return historicalRates;
}
},
COINGECKO("Coingecko") {
COINGECKO("Coingecko", "No historical rates") {
@Override
public List<Currency> getSupportedCurrencies() {
return getRates().rates.entrySet().stream().filter(rate -> "fiat".equals(rate.getValue().type) && isValidISO4217Code(rate.getKey().toUpperCase(Locale.ROOT)))
@@ -189,7 +192,7 @@ public enum ExchangeSource {
return historicalRates;
}
},
MEMPOOL_SPACE("mempool.space") {
MEMPOOL_SPACE("mempool.space", "Historical rates from Apr 2023") {
@Override
public List<Currency> getSupportedCurrencies() {
return getRates().rates.entrySet().stream().filter(price -> isValidISO4217Code(price.getKey().toUpperCase(Locale.ROOT)))
@@ -262,9 +265,11 @@ public enum ExchangeSource {
private static final Logger log = LoggerFactory.getLogger(ExchangeSource.class);
private final String name;
private final String description;
ExchangeSource(String name) {
ExchangeSource(String name, String description) {
this.name = name;
this.description = description;
}
public abstract List<Currency> getSupportedCurrencies();
@@ -282,11 +287,32 @@ public enum ExchangeSource {
}
}
public String getName() {
return name;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return name;
}
public SVGImage getSVGImage() {
try {
URL url = AppServices.class.getResource("/image/exchangesource/" + name.toLowerCase(Locale.ROOT) + "-icon.svg");
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
log.error("Could not load exchange source image for " + name);
}
return null;
}
public static class CurrenciesService extends Service<List<Currency>> {
private final ExchangeSource exchangeSource;
@@ -1,13 +1,19 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.BlockSummary;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.net.URL;
import java.util.*;
public enum FeeRatesSource {
ELECTRUM_SERVER("Server", false) {
@@ -24,11 +30,34 @@ public enum FeeRatesSource {
MEMPOOL_SPACE("mempool.space", true) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/fees/recommended" : "https://mempool.space/api/v1/fees/recommended";
String url = getApiUrl() + "v1/fees/recommended";
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
@Override
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
String url = getApiUrl() + "v1/block/" + Utils.bytesToHex(blockId.getReversedBytes());
return requestBlockSummary(this, url);
}
@Override
public Map<Integer, BlockSummary> getRecentBlockSummaries() throws Exception {
String url = getApiUrl() + "v1/blocks";
return requestBlockSummaries(this, url);
}
@Override
public List<BlockTransactionHash> getRecentMempoolTransactions() throws Exception {
String url = getApiUrl() + "mempool/recent";
return requestRecentMempoolTransactions(this, url);
}
private String getApiUrl() {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/" : "https://mempool.space/api/";
if(Network.get() != Network.MAINNET && supportsNetwork(Network.get())) {
url = url.replace("/api/", "/" + Network.get().getName() + "/api/");
}
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
return url;
}
@Override
@@ -101,6 +130,18 @@ public enum FeeRatesSource {
public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates);
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
throw new UnsupportedOperationException(name + " does not support block summaries");
}
public Map<Integer, BlockSummary> getRecentBlockSummaries() throws Exception {
throw new UnsupportedOperationException(name + " does not support block summaries");
}
public List<BlockTransactionHash> getRecentMempoolTransactions() throws Exception {
throw new UnsupportedOperationException(name + " does not support recent mempool transactions");
}
public abstract boolean supportsNetwork(Network network);
public String getName() {
@@ -158,11 +199,106 @@ public enum FeeRatesSource {
return httpClientService.requestJson(url, ThreeTierRates.class, null);
}
protected static BlockSummary requestBlockSummary(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) {
log.info("Requesting block summary from " + url);
}
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolBlockSummary mempoolBlockSummary = feeRatesSource.requestBlockSummary(url, httpClientService);
return mempoolBlockSummary.toBlockSummary();
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving block summary from " + url, e);
} else {
log.warn("Error retrieving block summary from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolBlockSummary requestBlockSummary(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolBlockSummary.class, null);
}
protected static Map<Integer, BlockSummary> requestBlockSummaries(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) {
log.info("Requesting block summaries from " + url);
}
Map<Integer, BlockSummary> blockSummaryMap = new LinkedHashMap<>();
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolBlockSummary[] blockSummaries = feeRatesSource.requestBlockSummaries(url, httpClientService);
for(MempoolBlockSummary blockSummary : blockSummaries) {
if(blockSummary.height != null) {
blockSummaryMap.put(blockSummary.height, blockSummary.toBlockSummary());
}
}
return blockSummaryMap;
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving block summaries from " + url, e);
} else {
log.warn("Error retrieving block summaries from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolBlockSummary[] requestBlockSummaries(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolBlockSummary[].class, null);
}
protected List<BlockTransactionHash> requestRecentMempoolTransactions(FeeRatesSource feeRatesSource, String url) throws Exception {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolRecentTransaction[] recentTransactions = feeRatesSource.requestRecentMempoolTransactions(url, httpClientService);
return Arrays.stream(recentTransactions).sorted().map(tx -> (BlockTransactionHash)new BlockTransaction(tx.txid, 0, null, tx.fee, null)).toList();
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving recent mempool transactions from " + url, e);
} else {
log.warn("Error retrieving recent mempool from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolRecentTransaction[] requestRecentMempoolTransactions(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolRecentTransaction[].class, null);
}
@Override
public String toString() {
return name;
}
public String getDescription() {
return switch(this) {
case ELECTRUM_SERVER -> "server";
case MINIMUM -> "settings";
default -> getName().toLowerCase(Locale.ROOT);
};
}
public SVGImage getSVGImage() {
try {
URL url = AppServices.class.getResource("/image/feeratesource/" + getDescription() + "-icon.svg");
if(url != null) {
return SVGLoader.load(url);
}
} catch(Exception e) {
log.error("Could not load fee rates source image for " + name);
}
return null;
}
protected record ThreeTierRates(Double fastestFee, Double halfHourFee, Double hourFee, Double minimumFee) {}
private record OxtRates(OxtRatesData[] data) {}
@@ -172,4 +308,30 @@ public enum FeeRatesSource {
return new ThreeTierRates(recommended_fee_099/1000, recommended_fee_090/1000, recommended_fee_050/1000, null);
}
}
protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, Integer weight, MempoolBlockSummaryExtras extras) {
public Double getMedianFee() {
return extras == null ? null : extras.medianFee();
}
public BlockSummary toBlockSummary() {
if(height == null || timestamp == null) {
throw new IllegalStateException("Height = " + height + ", timestamp = " + timestamp + ": both must be specified");
}
return new BlockSummary(height, new Date(timestamp * 1000), getMedianFee(), tx_count, weight);
}
}
private record MempoolBlockSummaryExtras(Double medianFee) {}
protected record MempoolRecentTransaction(Sha256Hash txid, Long fee, Long vsize) implements Comparable<MempoolRecentTransaction> {
private Double getFeeRate() {
return fee == null || vsize == null ? 0.0d : (double)fee / vsize;
}
@Override
public int compareTo(MempoolRecentTransaction o) {
return Double.compare(o.getFeeRate(), getFeeRate());
}
}
}
@@ -16,6 +16,10 @@ package com.sparrowwallet.sparrow.net;
* limitations under the License.
*/
import com.sparrowwallet.sparrow.AppServices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetAddress;
import java.net.UnknownHostException;
@@ -32,6 +36,8 @@ import java.net.UnknownHostException;
* Slightly modified by omidzk to have zero dependency to any frameworks other than the JRE.
*/
public final class IpAddressMatcher {
private static final Logger log = LoggerFactory.getLogger(IpAddressMatcher.class);
private static final IpAddressMatcher LOCAL_RANGE_1 = new IpAddressMatcher("10.0.0.0/8");
private static final IpAddressMatcher LOCAL_RANGE_2 = new IpAddressMatcher("172.16.0.0/12");
private static final IpAddressMatcher LOCAL_RANGE_3 = new IpAddressMatcher("192.168.0.0/16");
@@ -102,6 +108,15 @@ public final class IpAddressMatcher {
}
public static boolean isLocalNetworkAddress(String address) {
return "localhost".equals(address) || "127.0.0.1".equals(address) || LOCAL_RANGE_1.matches(address) || LOCAL_RANGE_2.matches(address) || LOCAL_RANGE_3.matches(address) || LOCAL_RANGE_4.matches(address);
try {
return "localhost".equals(address) || "127.0.0.1".equals(address) || LOCAL_RANGE_1.matches(address) || LOCAL_RANGE_2.matches(address) || LOCAL_RANGE_3.matches(address) || LOCAL_RANGE_4.matches(address);
} catch(IllegalArgumentException e) {
if(AppServices.isUsingProxy()) {
log.info(e.getMessage() + ", assuming it is a non-local address to be resolved by the configured proxy");
return false;
}
throw e;
}
}
}
@@ -129,7 +129,7 @@ public enum Protocol {
public abstract CloseableTransport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public HostAndPort getServerHostAndPort(String url) {
String lessProtocol = url.substring(this.toUrlString().length());
String lessProtocol = url.endsWith(":t") || url.endsWith(":s") ? url.substring(0, url.length() - 2) : url.substring(this.toUrlString().length());
int pathStart = lessProtocol.indexOf('/');
return HostAndPort.fromString(pathStart < 0 ? lessProtocol : lessProtocol.substring(0, pathStart));
}
@@ -178,10 +178,10 @@ public enum Protocol {
}
public static Protocol getProtocol(String url) {
if(url.startsWith("tcp://")) {
if(url.startsWith("tcp://") || url.endsWith(":t")) {
return TCP;
}
if(url.startsWith("ssl://")) {
if(url.startsWith("ssl://") || url.endsWith(":s")) {
return SSL;
}
if(url.startsWith("http://")) {
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.io.Server;
@@ -61,6 +62,16 @@ public enum PublicElectrumServer {
return null;
}
public static boolean isPublicServer(HostAndPort hostAndPort) {
for(PublicElectrumServer publicServer : values()) {
if(publicServer.getServer().getHostAndPort().equals(hostAndPort)) {
return true;
}
}
return false;
}
@Override
public String toString() {
return server.getAlias();
@@ -4,7 +4,7 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
class ScriptHashTx {
public class ScriptHashTx {
public static final ScriptHashTx ERROR_TX = new ScriptHashTx() {
@Override
public BlockTransactionHash getBlockchainTransactionHash() {
@@ -16,6 +16,14 @@ class ScriptHashTx {
public String tx_hash;
public long fee;
public ScriptHashTx() {}
public ScriptHashTx(int height, String tx_hash, long fee) {
this.height = height;
this.tx_hash = tx_hash;
this.fee = fee;
}
public BlockTransactionHash getBlockchainTransactionHash() {
Sha256Hash hash = Sha256Hash.wrap(tx_hash);
return new BlockTransaction(hash, height, null, fee, null);
@@ -5,15 +5,29 @@ import com.sparrowwallet.sparrow.AppServices;
public class ServerCapability {
private final boolean supportsBatching;
private final int maxTargetBlocks;
private final boolean supportsRecentMempool;
private final boolean supportsBlockStats;
public ServerCapability(boolean supportsBatching) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = AppServices.TARGET_BLOCKS_RANGE.getLast();
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast());
}
public ServerCapability(boolean supportsBatching, int maxTargetBlocks) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = false;
this.supportsBlockStats = false;
}
public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats);
}
public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsRecentMempool, boolean supportsBlockStats) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = supportsRecentMempool;
this.supportsBlockStats = supportsBlockStats;
}
public boolean supportsBatching() {
@@ -23,4 +37,12 @@ public class ServerCapability {
public int getMaxTargetBlocks() {
return maxTargetBlocks;
}
public boolean supportsRecentMempool() {
return supportsRecentMempool;
}
public boolean supportsBlockStats() {
return supportsBlockStats;
}
}
@@ -123,9 +123,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path));
try {
String scriptHash = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
String scriptHashStatus = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
result.put(path, scriptHash);
result.put(path, scriptHashStatus);
} catch(Exception e) {
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
throw new ElectrumServerRpcException("Failed to subscribe to path: " + path, e);
@@ -135,6 +135,24 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
return result;
}
@Override
public Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, Boolean> result = new LinkedHashMap<>();
for(String scriptHash : scriptHashes) {
try {
Boolean wasSubscribed = new RetryLogic<Boolean>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(Boolean.class).method("blockchain.scripthash.unsubscribe").id(idCounter.incrementAndGet()).params(scriptHash).executeNullable());
result.put(scriptHash, wasSubscribed);
} catch(Exception e) {
log.warn("Failed to unsubscribe from script hash: " + scriptHash, e);
}
}
return result;
}
@Override
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
@@ -159,6 +177,29 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
return result;
}
@Override
public Map<Integer, BlockStats> getBlockStats(Transport transport, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<Integer, BlockStats> result = new LinkedHashMap<>();
for(Integer blockHeight : blockHeights) {
try {
BlockStats blockStats = new RetryLogic<BlockStats>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(BlockStats.class).method("blockchain.block.stats").id(idCounter.incrementAndGet()).params(blockHeight).execute());
result.put(blockHeight, blockStats);
} catch(ServerException e) {
//If there is an error with the server connection, don't keep trying - this may take too long given many blocks
throw new ElectrumServerRpcException("Failed to retrieve block stats for block height: " + blockHeight, e);
} catch(JsonRpcException e) {
log.warn("Failed to retrieve block stats for block height: " + blockHeight + (e.getErrorMessage() != null ? " (" + e.getErrorMessage().getMessage() + ")" : ""));
} catch(Exception e) {
log.warn("Failed to retrieve block stats for block height: " + blockHeight + " (" + e.getMessage() + ")");
}
}
return result;
}
@Override
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);
@@ -20,6 +20,7 @@ public class SubscriptionService {
@JsonRpcMethod("blockchain.headers.subscribe")
public void newBlockHeaderTip(@JsonRpcParam("header") final BlockHeaderTip header) {
ElectrumServer.updateRetrievedBlockHeaders(header.height, header.getBlockHeader());
Platform.runLater(() -> EventManager.get().post(new NewBlockEvent(header.height, header.getBlockHeader())));
}
@@ -27,8 +28,7 @@ public class SubscriptionService {
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) {
List<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
if(existingStatuses == null) {
log.debug("Received script hash status update for unsubscribed script hash: " + scriptHash);
ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status);
log.trace("Received script hash status update for non-wallet script hash: " + scriptHash);
} else if(status != null && existingStatuses.contains(status)) {
log.debug("Received script hash status update, but status has not changed");
return;
@@ -1,134 +1,89 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.IOUtils;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import com.sparrowwallet.sparrow.io.Storage;
import io.matthewnelson.kmp.tor.KmpTorLoaderJvm;
import io.matthewnelson.kmp.tor.PlatformInstaller;
import io.matthewnelson.kmp.tor.TorConfigProviderJvm;
import io.matthewnelson.kmp.tor.binary.extract.TorBinaryResource;
import io.matthewnelson.kmp.tor.common.address.Port;
import io.matthewnelson.kmp.tor.common.address.ProxyAddress;
import io.matthewnelson.kmp.tor.controller.common.config.TorConfig;
import io.matthewnelson.kmp.tor.controller.common.file.Path;
import io.matthewnelson.kmp.tor.ext.callback.manager.CallbackTorManager;
import io.matthewnelson.kmp.tor.manager.TorManager;
import io.matthewnelson.kmp.tor.manager.common.event.TorManagerEvent;
import io.matthewnelson.kmp.tor.manager.util.PortUtil;
import org.jetbrains.annotations.NotNull;
import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec;
import io.matthewnelson.kmp.tor.runtime.Action;
import io.matthewnelson.kmp.tor.runtime.RuntimeEvent;
import io.matthewnelson.kmp.tor.runtime.TorListeners;
import io.matthewnelson.kmp.tor.runtime.TorRuntime;
import io.matthewnelson.kmp.tor.runtime.core.OnEvent;
import io.matthewnelson.kmp.tor.runtime.core.OnFailure;
import io.matthewnelson.kmp.tor.runtime.core.OnSuccess;
import io.matthewnelson.kmp.tor.runtime.core.TorEvent;
import io.matthewnelson.kmp.tor.runtime.core.ctrl.TorCmd;
import io.matthewnelson.kmp.tor.runtime.core.net.IPSocketAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Closeable;
import java.io.File;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.List;
import java.nio.file.Path;
public class Tor {
public class Tor implements Closeable {
private static final Logger log = LoggerFactory.getLogger(Tor.class);
public static final String TOR_DIR = "tor";
public static final String WORK_DIR = "work";
public static final String CACHE_DIR = "cache";
public static final String OLD_INSTALL_DIR = ".kmptor";
public static final String TOR_ADDRESS_SUFFIX = ".onion";
private static Tor tor;
private final Path path = Path.invoke(Storage.getSparrowHome().getAbsolutePath()).builder().addSegment(TOR_DIR).build();
private final CallbackTorManager instance;
private ProxyAddress socksAddress;
private final TorRuntime instance;
private IPSocketAddress socksAddress;
public Tor() {
OsType osType = OsType.getCurrent();
String arch = System.getProperty("os.arch");
PlatformInstaller installer;
PlatformInstaller.InstallOption installOption = PlatformInstaller.InstallOption.CleanInstallIfMissing;
if(osType == OsType.MACOS) {
if(arch.equals("aarch64")) {
installer = PlatformInstaller.macosArm64(installOption);
} else {
installer = PlatformInstaller.macosX64(installOption);
}
} else if(osType == OsType.WINDOWS) {
installer = PlatformInstaller.mingwX64(installOption);
} else if(osType == OsType.UNIX) {
if(arch.equals("aarch64")) {
TorBinaryResource linuxArm64 = TorBinaryResource.from(TorBinaryResource.OS.Linux, "arm64",
"588496f3164d52b91f17e4db3372d8dfefa6366a8df265eebd4a28d4128992aa",
List.of("libevent-2.1.so.7.gz", "libstdc++.so.6.gz", "libcrypto.so.1.1.gz", "tor.gz", "libssl.so.1.1.gz"));
installer = PlatformInstaller.custom(installOption, linuxArm64);
} else {
installer = PlatformInstaller.linuxX64(installOption);
}
} else {
throw new UnsupportedOperationException("Sparrow's bundled Tor is not supported on " + osType + " " + arch);
public Tor(OnEvent<TorListeners> listener) {
Path path = Path.of(Storage.getSparrowHome().getAbsolutePath()).resolve(TOR_DIR);
File oldInstallDir = path.resolve(WORK_DIR).resolve(OLD_INSTALL_DIR).toFile();
if(oldInstallDir.exists()) {
IOUtils.deleteDirectory(oldInstallDir);
}
TorConfigProviderJvm torConfigProviderJvm = new TorConfigProviderJvm() {
@NotNull
@Override
public Path getWorkDir() {
return path.builder().addSegment("work").build();
}
TorRuntime.Environment env = TorRuntime.Environment.Builder(
path.resolve(WORK_DIR).toFile(),
path.resolve(CACHE_DIR).toFile(),
ResourceLoaderTorExec::getOrCreate
);
@NotNull
@Override
public Path getCacheDir() {
return path.builder().addSegment("cache").build();
}
env.debug = true;
@NotNull
@Override
protected TorConfig provide() {
TorConfig.Builder builder = new TorConfig.Builder();
TorConfig.Setting.DormantCanceledByStartup dormantCanceledByStartup = new TorConfig.Setting.DormantCanceledByStartup();
dormantCanceledByStartup.set(TorConfig.Option.AorTorF.getTrue());
builder.put(dormantCanceledByStartup);
TorConfig.Setting.Ports.Control controlPort = new TorConfig.Setting.Ports.Control();
controlPort.set(TorConfig.Option.AorDorPort.Auto.INSTANCE);
builder.put(controlPort);
return builder.build();
}
};
KmpTorLoaderJvm jvmLoader = new KmpTorLoaderJvm(installer, torConfigProviderJvm);
TorManager torManager = TorManager.newInstance(jvmLoader);
torManager.debug(true);
torManager.addListener(new TorManagerEvent.Listener() {
@Override
public void managerEventWarn(@NotNull String message) {
log.warn(message);
EventManager.get().post(new TorStatusEvent(message));
}
@Override
public void managerEventInfo(@NotNull String message) {
log.info(message);
EventManager.get().post(new TorStatusEvent(message));
}
@Override
public void managerEventDebug(@NotNull String message) {
log.debug(message);
EventManager.get().post(new TorStatusEvent(message));
}
@Override
public void managerEventAddressInfo(@NotNull TorManagerEvent.AddressInfo info) {
if(info.isNull) {
socksAddress = null;
} else {
socksAddress = info.socksInfoToProxyAddress().iterator().next();
instance = TorRuntime.Builder(env, b -> {
RuntimeEvent.entries().forEach(event -> {
switch(event) {
case RuntimeEvent.LOG.ERROR _ -> b.observerStatic(event, OnEvent.Executor.Immediate.INSTANCE, data -> {
log.error(data.toString());
EventManager.get().post(new TorStatusEvent(data.toString()));
});
case RuntimeEvent.LOG.WARN _ -> b.observerStatic(event, OnEvent.Executor.Immediate.INSTANCE, data -> {
log.warn(data.toString());
EventManager.get().post(new TorStatusEvent(data.toString()));
});
case RuntimeEvent.LOG.INFO _ -> b.observerStatic(event, OnEvent.Executor.Immediate.INSTANCE, data -> {
log.info(data.toString());
EventManager.get().post(new TorStatusEvent(data.toString()));
});
case RuntimeEvent.LOG.DEBUG _ -> b.observerStatic(event, OnEvent.Executor.Immediate.INSTANCE, data -> {
log.debug(data.toString());
EventManager.get().post(new TorStatusEvent(data.toString()));
});
case null, default -> b.observerStatic(event, OnEvent.Executor.Immediate.INSTANCE, data -> {
log.trace(data.toString());
});
}
}
});
});
this.instance = new CallbackTorManager(torManager, uncaughtException -> {
log.error("Uncaught exception from CallbackTorManager", uncaughtException);
b.observerStatic(RuntimeEvent.LISTENERS.INSTANCE, new TorSocksClient());
b.observerStatic(RuntimeEvent.LISTENERS.INSTANCE, listener);
b.required(TorEvent.ERR.INSTANCE);
b.required(TorEvent.WARN.INSTANCE);
});
}
@@ -140,7 +95,7 @@ public class Tor {
Tor.tor = tor;
}
public CallbackTorManager getTorManager() {
public TorRuntime getTorRuntime() {
return instance;
}
@@ -149,14 +104,34 @@ public class Tor {
throw new IllegalStateException("Tor socks proxy is not yet instantiated");
}
return new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksAddress.address.getValue(), socksAddress.port.getValue()));
return new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(socksAddress.address.value, socksAddress.port.value));
}
public HostAndPort getProxyHostAndPort() {
return HostAndPort.fromParts(socksAddress.address.getValue(), socksAddress.port.getValue());
return HostAndPort.fromParts(socksAddress.address.value, socksAddress.port.value);
}
public static boolean isRunningExternally() {
return !PortUtil.isTcpPortAvailable(Port.invoke(9050));
public void changeIdentity() {
if(instance != null) {
instance.enqueue(TorCmd.Signal.NewNym.INSTANCE, throwable -> {
log.warn("Failed to signal newnym", throwable);
}, _ -> {
log.info("Signalled newnym for new Tor circuit");
});
}
}
@Override
public void close() {
if(instance != null) {
instance.enqueue(Action.StopDaemon, OnFailure.noOp(), OnSuccess.noOp());
}
}
private class TorSocksClient implements OnEvent<TorListeners> {
@Override
public void invoke(TorListeners torListeners) {
socksAddress = torListeners.socks.isEmpty() ? null : torListeners.socks.iterator().next();
}
}
}
@@ -1,10 +1,12 @@
package com.sparrowwallet.sparrow.net;
import io.matthewnelson.kmp.tor.ext.callback.manager.CallbackTorManager;
import io.matthewnelson.kmp.tor.manager.common.event.TorManagerEvent;
import io.matthewnelson.kmp.tor.runtime.Action;
import io.matthewnelson.kmp.tor.runtime.TorListeners;
import io.matthewnelson.kmp.tor.runtime.TorRuntime;
import io.matthewnelson.kmp.tor.runtime.core.OnEvent;
import io.matthewnelson.kmp.tor.runtime.core.ctrl.Reply;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -32,37 +34,25 @@ public class TorService extends ScheduledService<Tor> {
protected Tor call() throws Exception {
Tor tor = Tor.getDefault();
if(tor == null) {
tor = new Tor();
CallbackTorManager callbackTorManager = tor.getTorManager();
callbackTorManager.addListener(new TorManagerEvent.Listener() {
@Override
public void managerEventAddressInfo(@NotNull TorManagerEvent.AddressInfo info) {
if(!info.isNull) {
try {
startupLock.lock();
startupCondition.signalAll();
} finally {
startupLock.unlock();
}
}
}
});
callbackTorManager.start(throwable -> {
if(throwable instanceof Exception exception) {
tor = new Tor(new StartupListener());
TorRuntime torRuntime = tor.getTorRuntime();
torRuntime.enqueue(Action.StartDaemon, throwable -> {
if(throwable instanceof Reply.Error replyError && !replyError.replies.isEmpty()) {
startupException = new TorStartupException(replyError.replies.getFirst().message);
} else if(throwable instanceof Exception exception) {
startupException = exception;
} else {
startupException = new Exception(throwable);
}
log.error("Error", throwable);
log.error("Error starting Tor daemon", throwable);
try {
startupLock.lock();
startupCondition.signalAll();
} finally {
startupLock.unlock();
}
}, success -> {
log.info("Tor daemon started successfully");
});
}, _ -> log.info("Tor daemon started successfully"));
try {
startupLock.lock();
@@ -82,4 +72,18 @@ public class TorService extends ScheduledService<Tor> {
}
};
}
private class StartupListener implements OnEvent<TorListeners> {
@Override
public void invoke(TorListeners torListeners) {
if(!torListeners.socks.isEmpty()) {
try {
startupLock.lock();
startupCondition.signalAll();
} finally {
startupLock.unlock();
}
}
}
}
}
@@ -3,7 +3,6 @@ package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.sparrow.AppServices;
import io.matthewnelson.kmp.tor.controller.common.control.usecase.TorControlSignal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -13,7 +12,6 @@ import java.net.SocketTimeoutException;
import java.nio.file.AccessDeniedException;
import java.nio.file.FileSystemException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -24,11 +22,7 @@ public class TorUtils {
public static void changeIdentity(HostAndPort proxy) {
if(AppServices.isTorRunning()) {
Tor.getDefault().getTorManager().signal(TorControlSignal.Signal.NewNym, throwable -> {
log.warn("Failed to signal newnym");
}, successEvent -> {
log.info("Signalled newnym for new Tor circuit");
});
Tor.getDefault().changeIdentity();
} else {
HostAndPort control = HostAndPort.fromParts(proxy.getHost(), proxy.getPort() + 1);
try(Socket socket = new Socket(control.getHost(), control.getPort())) {
@@ -10,7 +10,7 @@ import com.sparrowwallet.sparrow.AppServices;
import java.util.Date;
@JsonIgnoreProperties(ignoreUnknown = true)
class VerboseTransaction {
public class VerboseTransaction {
public String blockhash;
public long blocktime;
public int confirmations;
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
public class BitcoinRPCException extends RuntimeException {
public BitcoinRPCException(String message) {
super(message);
}
}
@@ -49,6 +49,12 @@ public class BitcoindClient {
private static final long PRUNED_RESCAN_TIMEGAP_MILLIS = 7200*1000;
//Error codes from https://github.com/bitcoin/bitcoin/blob/master/src/rpc/protocol.h
public static final int RPC_METHOD_NOT_FOUND = -32601;
public static final int RPC_WALLET_NOT_FOUND = -18;
public static final String WALLET_ALREADY_LOADING_MESSAGE = "Wallet already loading.";
private final JsonRpcClient jsonRpcClient;
private final Timer timer = new Timer(true);
private final Store store = new Store();
@@ -140,21 +146,27 @@ public class BitcoindClient {
tip = blockHeader.getBlockHeader();
}
ListWalletDirResult listWalletDirResult = getBitcoindService().listWalletDir();
if(listWalletDirResult == null) {
throw new RuntimeException("Wallet support must be enabled in Bitcoin Core");
List<String> loadedWallets;
try {
loadedWallets = getBitcoindService().listWallets();
legacyWalletExists = loadedWallets.contains(Bwt.DEFAULT_CORE_WALLET);
} catch(JsonRpcException e) {
if(e.getErrorMessage().getCode() == RPC_METHOD_NOT_FOUND) {
throw new BitcoinRPCException("Wallet support must be enabled in Bitcoin Core");
} else {
throw e;
}
}
boolean exists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(CORE_WALLET_NAME));
legacyWalletExists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(Bwt.DEFAULT_CORE_WALLET));
List<String> loadedWallets = getBitcoindService().listWallets();
boolean loaded = loadedWallets.contains(CORE_WALLET_NAME);
if(!exists && !loaded) {
getBitcoindService().createWallet(CORE_WALLET_NAME, true, true, "", true, true, true, false);
} else {
if(!loaded) {
if(!loadedWallets.contains(CORE_WALLET_NAME)) {
try {
getBitcoindService().loadWallet(CORE_WALLET_NAME, true);
} catch(JsonRpcException e) {
if(e.getErrorMessage().getCode() == RPC_WALLET_NOT_FOUND) {
getBitcoindService().createWallet(CORE_WALLET_NAME, true, true, "", true, true, true, false);
} else if(!WALLET_ALREADY_LOADING_MESSAGE.equals(e.getErrorMessage().getMessage())) {
throw e;
}
}
}
@@ -186,12 +198,24 @@ public class BitcoindClient {
.sorted(Comparator.comparingLong(o -> o.getBirthDate().getTime())).collect(Collectors.toList());
if(!prePruneWallets.isEmpty()) {
pruneWarnedDescriptors.add(e.getDescriptor());
Platform.runLater(() -> EventManager.get().post(new CormorantPruneStatusEvent("Error: Wallet birthday earlier than Bitcoin Core prune date", prePruneWallets.get(0), e.getRescanSince(), e.getPrunedDate(), legacyWalletExists)));
if(!legacyWalletExists) {
legacyWalletExists = checkLegacyWalletExists();
}
Platform.runLater(() -> EventManager.get().post(new CormorantPruneStatusEvent("Error: Wallet birthday earlier than Bitcoin Core prune date", prePruneWallets.getFirst(), e.getRescanSince(), e.getPrunedDate(), legacyWalletExists)));
}
throw new ImportFailedException("Wallet birthday earlier than prune date");
}
}
private boolean checkLegacyWalletExists() {
try {
getBitcoindService().loadWallet(Bwt.DEFAULT_CORE_WALLET);
return true;
} catch(Exception e) {
return false;
}
}
public void importWallet(Wallet wallet) throws ImportFailedException {
//To avoid unnecessary rescans, get all related wallets
importWallets(wallet.isMasterWallet() ? wallet.getAllWallets() : wallet.getMasterWallet().getAllWallets());

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