Compare commits

...

91 commits

Author SHA1 Message Date
Craig Raw
3a5fa69fb6 fix occasional issue with cell reuse when avoiding updating cells during table size estimation 2025-11-05 12:20:02 +02:00
Craig Raw
4774830ce4 add yu12 to supported pixel formats on linux 2025-11-05 07:51:26 +02:00
Craig Raw
2f62a9e9c8 show signing keystores in transaction blockchain form for spends from multisig wallets 2025-11-04 10:45:31 +02:00
doblon8
75bcfe2253
update jzbar dependency to 0.2.1 2025-10-24 09:38:11 +02:00
Craig Raw
bedf1399ea request display of path when retreiving xpubs on ledger devices for any non-standard path 2025-10-24 08:38:48 +02:00
Craig Raw
58575793ea update openpnp-capture to 0.0.30-1 2025-10-21 15:55:32 +02:00
Craig Raw
6c9b580d4f refactor to use transaction parameters record object when creating a wallet transaction 2025-10-21 12:06:00 +02:00
doblon8
31909b7a15
use language-independent sid for windows users group permission 2025-10-21 09:35:18 +02:00
Craig Raw
092267339a adapt to use declarative style to for consolidation payments 2025-10-17 10:27:20 +02:00
Craig Raw
0974918cff hide confirmations in tooltip when showing inputs and outputs on the transactions table 2025-10-16 08:42:55 +02:00
Craig Raw
0f4c36b3c2 add ctrl+shift+left/right keyboard shortcuts for moving tabs left and right 2025-10-13 14:26:37 +02:00
Craig Raw
e1fe35fb74 update nsmenufx to avoid npe 2025-10-06 14:52:24 +02:00
Craig Raw
d37fd00c4b avoid using deprecated camera device type constants on recent macos versions 2025-10-06 13:27:12 +02:00
Craig Raw
5f54f86df7 bump to v2.3.1 2025-10-03 11:53:27 +02:00
Craig Raw
e2fa3df08d restore pre gradle 9 archive task behaviour for file permissions 2025-10-03 10:09:57 +02:00
Craig Raw
6d6ede9abe bump to v2.3.0 2025-10-03 08:08:25 +02:00
Craig Raw
cca9ab1056 improve implementation of adding dns payment information from psbt 2025-10-02 14:58:02 +02:00
Craig Raw
9e33861110 revert to javafx 23 due to jpackage launcher link bug 2025-10-01 12:10:39 +02:00
Craig Raw
c3d3fd1fda revert to java 22 and javafx 24 due to bug in jpackage launcher linking (jdk-8345810) 2025-10-01 11:52:45 +02:00
Craig Raw
ca8553ecb8 revert continuity camera device change as unsupported on macos 13 2025-09-30 12:33:33 +02:00
Craig Raw
d23ee8c086 upgrade openpnp-capture to iterate over continuity camera devices on mac 2025-09-30 12:24:22 +02:00
Craig Raw
e776a17ad4 upgrade jdbi to remove older caffeine dependency 2025-09-30 09:37:45 +02:00
Craig Raw
480ce1e476 fix deprecation warning 2025-09-29 17:39:48 +02:00
Craig Raw
656cd90b08 upgrade guava and commons-lang3 2025-09-29 14:34:07 +02:00
Craig Raw
8df0777959 upgrade to java 25 and javafx 25 2025-09-29 13:32:56 +02:00
Craig Raw
84566b92e6 remove unnecessary zbar native libraries 2025-09-29 12:39:49 +02:00
Craig Raw
7802510e58 support dns hrns in send to many dialog 2025-09-29 11:53:52 +02:00
Craig Raw
efb1eb1051 add initial sending to silent payments support 2025-09-29 08:37:07 +02:00
Craig Raw
6240667478 improve error dialog on payjoin receiver error 2025-09-02 09:49:39 +02:00
Craig Raw
2c27112dad update drongo 2025-08-16 13:03:03 +02:00
Craig Raw
6d53e1ed1d fix bluewallet spelling 2025-08-12 08:09:01 +02:00
Craig Raw
e8c5660897 allow transaction diagram input and output labels to expand into available width 2025-08-11 14:03:51 +02:00
Craig Raw
bef6c750bd upgrade to gradle 8.14.3 2025-08-07 11:28:27 +02:00
Craig Raw
4ec3603789 fix non bip32 child derivation test 2025-08-07 08:55:19 +02:00
Craig Raw
90c9f9733f display a warning if an output descriptor provided in the wallet settings will be modified for use 2025-08-05 09:28:52 +02:00
Craig Raw
64efcf67d3 display zero byte length witness elements as empty instead of op_0 2025-08-04 13:38:41 +02:00
Craig Raw
385d173948 handle npe connecting to bitcoin core with wallet functionality disabled 2025-08-01 07:44:34 +02:00
Craig Raw
d81b868049 add any dns payment instructions from loaded psbts if not already cached 2025-07-31 11:47:43 +02:00
Craig Raw
2ff7a15d1e add padding to writes when connected over tls 2025-07-31 10:34:56 +02:00
Craig Raw
f48fa7e23c support zero in pin keypad for onekey classic pin entry 2025-07-29 14:45:30 +02:00
Craig Raw
4632850e1e use improved dnssec validation and handle offline state when resolving bip 353 hrns 2025-07-29 12:52:10 +02:00
Craig Raw
5f62523710 support sending to and displaying bip353 human readable names and include dnssec proof in associated psbts 2025-07-24 14:36:11 +02:00
Craig Raw
9dcf210762 support creating transactions with the minimum relay fee rate configured by the user or set by the connected server 2025-07-17 09:15:59 +02:00
Craig Raw
c7e9a0a161 restore coingecko historical rate support by limiting to the last 365 days 2025-07-15 09:06:52 +02:00
Craig Raw
fa10714844 followup 2025-07-10 08:40:31 +02:00
Craig Raw
80105aee62 save webcam device unique id instead of name to config 2025-07-10 08:15:00 +02:00
Craig Raw
3c5fa58a16 suppress warnings for jzbar ffm usage 2025-07-09 12:46:57 +02:00
doblon8
2a2be2617c
replace jni-based zbar wrapper with ffm-based jzbar
* Replace ZBar JNI library implementation with jzbar

* Move ZBar.java to com.sparrowwallet.sparrow.control package

* Move ZBar.java to the com.sparrowwallet.sparrow.io package

* Switch to jzbar from Maven Central and update module/package imports accordingly

* Remove jzbar entry from extraJavaModuleInfo to avoid module patching error
2025-07-09 11:55:51 +02:00
Craig Raw
6c9a0d14cd compare on device unique id when choosing selected camera 2025-07-09 10:58:58 +02:00
Craig Raw
f82fcb58bb fix issue of including parent path elements in deterministic key when deriving child xpub from an output descriptor containing more than two child path elements 2025-07-09 10:26:49 +02:00
Craig Raw
5ec3bff6a4 fix jade configuration for signet and regtest networks 2025-07-02 16:45:10 +02:00
Oleg Koretsky
134dc826ba
do not change coin label unit on right click 2025-07-02 16:32:10 +02:00
Craig Raw
cd2a6623a4 fix restart menu options on linux standalone package 2025-07-01 16:07:45 +02:00
Craig Raw
49ab9e40e3 select first matching webcam by name 2025-06-28 16:10:57 +02:00
Craig Raw
cec7eac9ac fix selection of nearest supported resolution where chosen resolution is not available 2025-06-24 11:26:13 +02:00
Craig Raw
33e043fd9a include child derivations in output descriptor for bip 129 wallet export 2025-06-24 10:52:52 +02:00
Craig Raw
3aae26b196 bump to v2.2.4 2025-06-10 09:01:37 +02:00
Craig Raw
73d4fd5049 prevent double free when closing capture library 2025-06-09 14:43:06 +02:00
Craig Raw
a94380e882 minor specter diy ui tweaks 2025-06-07 11:23:06 +02:00
Craig Raw
e4dd4950bf prevent selection of unsupported bip322 format when signing a message with a connected device 2025-06-06 13:07:36 +02:00
Craig Raw
26ce1b3469 derive to maximum bip32 account level where child path in output descriptor contains more than two elements 2025-06-06 11:45:46 +02:00
Craig Raw
ebce34f3d1 minor tweaks 2025-06-05 14:23:02 +02:00
Craig Raw
f28e00b97e suggest opening the send to many dialog when adding multiple payments on the send tab 2025-06-05 10:31:37 +02:00
Craig Raw
25770c2426 suggest connecting to broadcast a finalized transaction if offlineand a server is configured 2025-06-05 09:40:17 +02:00
Craig Raw
799cac7b1f handle bitkey descriptor export format 2025-06-05 08:28:21 +02:00
Craig Raw
c265fd1969 fix cormorant server.version rpc issue 2025-06-04 17:18:31 +02:00
Craig Raw
890f0476b1 introduce delay before closing capture library 2025-06-04 15:23:48 +02:00
Craig Raw
4d93381124 improve electrum server script hash unsubscribe support 2025-06-04 14:52:33 +02:00
Craig Raw
364909cfa3 support nv12 capture pixel format on linux 2025-06-03 12:48:01 +02:00
Craig Raw
38f0068411 detect if electrum server supports scripthash unsubscribe capability 2025-06-03 12:38:03 +02:00
Craig Raw
8885e48ed9 request rgb3 pixel format on linux where returned format is unsupported 2025-06-02 16:28:44 +02:00
Craig Raw
31ce3ce68a further electrum server optimisations 2025-06-02 15:56:46 +02:00
Craig Raw
b0d0514617 remove possibility of task queueing in webcam service 2025-06-02 11:36:06 +02:00
Craig Raw
d7d23f9b58 always use the master wallet payment code when creating the notification transaction payload on the send tab 2025-06-02 09:41:46 +02:00
Craig Raw
3fdf093a26 use semaphore to ensure last webcam service task has completed before closing stream 2025-05-29 14:17:58 +02:00
Craig Raw
74c298fd93 iterate and remove faulty capture devices on opening qr scan dialog 2025-05-29 13:58:46 +02:00
Craig Raw
4298bfb053 bump to v2.2.3 2025-05-22 14:58:09 +02:00
Craig Raw
231eb13cee retrieve and show next block median fee rate in recent blocks view where available 2025-05-22 13:35:59 +02:00
Craig Raw
52470ee6d8 further electrum server optimization tweaks 2025-05-22 11:59:25 +02:00
Craig Raw
853949675e fix npe configuring recent blocks view on new installs 2025-05-22 08:44:39 +02:00
Craig Raw
098afebbe0 increase recent blocks estimated fee rate update frequency 2025-05-21 15:38:06 +02:00
Craig Raw
63c0a6d6e2 bump to v2.2.2 2025-05-21 13:29:16 +02:00
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
125 changed files with 2447 additions and 1601 deletions

View file

@ -12,11 +12,11 @@ jobs:
matrix:
os: [windows-2022, ubuntu-22.04, ubuntu-22.04-arm, macos-13, macos-14]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
submodules: true
- name: Set up JDK 22.0.2
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '22.0.2'
@ -30,6 +30,9 @@ jobs:
- name: Package tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Repackage deb distribution
if: ${{ runner.os == 'Linux' }}
run: ./repackage.sh
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
@ -43,6 +46,9 @@ jobs:
- name: Package headless tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew -Djava.awt.headless=true packageTarDistribution
- name: Repackage headless deb distribution
if: ${{ runner.os == 'Linux' }}
run: ./repackage.sh
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v4

View file

@ -1,9 +1,9 @@
plugins {
id 'application'
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.2'
id 'org.beryx.jlink' version '3.1.3'
id 'org.gradlex.extra-java-module-info' version '1.13'
id 'io.matthewnelson.kmp.tor.resource-filterjar' version '408.16.3'
}
def os = org.gradle.internal.os.OperatingSystem.current()
@ -19,17 +19,16 @@ if(System.getProperty("os.arch") == "aarch64") {
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
group 'com.sparrowwallet'
version '2.2.0'
group = 'com.sparrowwallet'
version = '2.3.1'
repositories {
mavenCentral()
maven { url 'https://code.sparrowwallet.com/api/packages/sparrowwallet/maven' }
maven { url = uri('https://code.sparrowwallet.com/api/packages/sparrowwallet/maven') }
}
tasks.withType(AbstractArchiveTask) {
preserveFileTimestamps = false
reproducibleFileOrder = true
tasks.withType(AbstractArchiveTask).configureEach {
useFileSystemPermissions()
}
javafx {
@ -45,20 +44,20 @@ dependencies {
//Any changes to the dependencies must be reflected in the module definitions below!
implementation(project(':drongo'))
implementation(project(':lark'))
implementation('com.google.guava:guava:33.0.0-jre')
implementation('com.google.guava:guava:33.5.0-jre')
implementation('com.google.code.gson:gson:2.9.1')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-core:3.20.0') {
implementation('org.jdbi:jdbi3-core:3.49.5') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0') {
implementation('org.jdbi:jdbi3-sqlobject:3.49.5') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:9.22.3')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('org.fxmisc.richtext:richtextfx:0.11.6')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') {
exclude group: 'com.beust', module: 'jcommander'
@ -74,13 +73,15 @@ 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('org.openpnp:openpnp-capture-java:0.0.28-5')
implementation('org.openpnp:openpnp-capture-java:0.0.30-1')
implementation("io.matthewnelson.kmp-tor:runtime:2.2.1")
implementation("io.matthewnelson.kmp-tor:resource-exec-tor-gpl:408.16.2")
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')
implementation('de.jangassen:nsmenufx:3.1.0') {
exclude group: 'net.java.dev.jna', module: 'jna'
}
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics'
@ -100,7 +101,7 @@ dependencies {
implementation('com.sparrowwallet:tern:1.0.6')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('org.apache.commons:commons-lang3:3.19.0')
implementation('org.apache.commons:commons-compress:1.27.1')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.30')
@ -109,6 +110,7 @@ dependencies {
implementation('com.github.hervegirod:fxsvgimage:1.1')
implementation('com.sparrowwallet:toucan:0.9.0')
implementation('com.jcraft:jzlib:1.1.3')
implementation('io.github.doblon8:jzbar:0.2.1')
testImplementation('org.junit.jupiter:junit-jupiter-api:5.10.0')
testRuntimeOnly('org.junit.jupiter:junit-jupiter-engine:5.10.0')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
@ -141,6 +143,12 @@ application {
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
"--enable-native-access=com.sparrowwallet.drongo",
"--enable-native-access=com.sun.jna",
"--enable-native-access=javafx.graphics",
"--enable-native-access=com.fazecast.jSerialComm",
"--enable-native-access=org.usb4java",
"--enable-native-access=io.github.doblon8.jzbar",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
@ -150,11 +158,6 @@ application {
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
@ -165,8 +168,7 @@ application {
"--add-reads=org.flywaydb.core=java.desktop"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow"]
}
if(headless) {
applicationDefaultJvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
@ -189,7 +191,14 @@ jlink {
options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', 'zip-6', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
launcher {
name = 'sparrow'
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
jvmArgs = ["--enable-native-access=com.sparrowwallet.drongo",
"--enable-native-access=com.sun.jna",
"--enable-native-access=javafx.graphics",
"--enable-native-access=com.sparrowwallet.merged.module",
"--enable-native-access=com.fazecast.jSerialComm",
"--enable-native-access=org.usb4java",
"--enable-native-access=io.github.doblon8.jzbar",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
@ -198,11 +207,6 @@ jlink {
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
@ -221,6 +225,7 @@ jlink {
"--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=com.sparrowwallet.merged.module=org.reactfx.reactfx",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
"--add-reads=org.flywaydb.core=java.desktop"]
@ -228,7 +233,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", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
if(headless) {
jvmArgs += ["-Dglass.platform=Monocle", "-Dmonocle.platform=Headless", "-Dprism.order=sw"]
@ -280,7 +285,8 @@ if(os.linux) {
tasks.register('addUserWritePermission', Exec) {
if(os.windows) {
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', 'Users:(OI)(CI)F', '/T'
def usersGroup = '*S-1-5-32-545' // Windows "Users" group SID (language-independent)
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', "${usersGroup}:(OI)(CI)F", '/T'
} else {
commandLine 'chmod', '-R', 'u+w', "$buildDir/image/legal"
}
@ -380,33 +386,12 @@ extraJavaModuleInfo {
requires('java.desktop')
requires('com.sun.jna')
}
module('de.codecentric.centerdevice:centerdevice-nsmenufx', 'centerdevice.nsmenufx') {
exports('de.codecentric.centerdevice')
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
}
module('net.sourceforge.javacsv:javacsv', 'net.sourceforge.javacsv') {
exports('com.csvreader')
}
module('com.google.guava:listenablefuture|empty-to-avoid-conflict-with-guava', 'com.google.guava.listenablefuture')
module('com.google.code.findbugs:jsr305', 'com.google.code.findbugs.jsr305')
module('j2objc-annotations-2.8.jar', 'com.google.j2objc.j2objc.annotations', '2.8')
module('org.jdbi:jdbi3-core', 'org.jdbi.v3.core') {
exports('org.jdbi.v3.core')
exports('org.jdbi.v3.core.mapper')
exports('org.jdbi.v3.core.statement')
exports('org.jdbi.v3.core.result')
exports('org.jdbi.v3.core.h2')
exports('org.jdbi.v3.core.spi')
requires('io.leangen.geantyref')
requires('java.sql')
requires('org.slf4j')
requires('com.github.benmanes.caffeine')
}
module('io.leangen.geantyref:geantyref', 'io.leangen.geantyref') {
exports('io.leangen.geantyref')
}
module('org.fxmisc.richtext:richtextfx', 'org.fxmisc.richtext') {
exports('org.fxmisc.richtext')
exports('org.fxmisc.richtext.event')
@ -416,10 +401,10 @@ extraJavaModuleInfo {
requires('javafx.graphics')
requires('org.fxmisc.flowless')
requires('org.reactfx.reactfx')
requires('org.fxmisc.undo.undofx')
requires('org.fxmisc.undo')
requires('org.fxmisc.wellbehaved')
}
module('org.fxmisc.undo:undofx', 'org.fxmisc.undo.undofx') {
module('org.fxmisc.undo:undofx', 'org.fxmisc.undo') {
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')

View file

@ -4,13 +4,12 @@ plugins {
dependencies {
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.3'
implementation 'org.javamodularity:moduleplugin:1.8.14'
}
repositories {
mavenCentral()
maven {
url "https://plugins.gradle.org/m2/"
url = uri("https://plugins.gradle.org/m2/")
}
}

View file

@ -32,7 +32,6 @@ package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetectorPlugin;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.javamodularity.moduleplugin.ModuleSystemPlugin;
import org.openjfx.gradle.tasks.ExecTask;
public class JavaFXPlugin implements Plugin<Project> {
@ -40,10 +39,9 @@ public class JavaFXPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().apply(OsDetectorPlugin.class);
project.getPlugins().apply(ModuleSystemPlugin.class);
project.getExtensions().create("javafx", JavaFXOptions.class, project);
project.getTasks().create("configJavafxRun", ExecTask.class, project);
project.getTasks().register("configJavafxRun", ExecTask.class, project);
}
}

View file

@ -33,27 +33,19 @@ import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.api.file.FileCollection;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.ApplicationPlugin;
import org.gradle.api.tasks.JavaExec;
import org.gradle.api.tasks.TaskAction;
import org.javamodularity.moduleplugin.extensions.RunModuleOptions;
import org.openjfx.gradle.JavaFXModule;
import org.openjfx.gradle.JavaFXOptions;
import org.openjfx.gradle.JavaFXPlatform;
import javax.inject.Inject;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TreeSet;
public class ExecTask extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(ExecTask.class);
private final Project project;
private JavaExec execTask;
@ -78,37 +70,11 @@ public class ExecTask extends DefaultTask {
var definedJavaFXModuleNames = new TreeSet<>(javaFXOptions.getModules());
if (!definedJavaFXModuleNames.isEmpty()) {
RunModuleOptions moduleOptions = execTask.getExtensions().findByType(RunModuleOptions.class);
final FileCollection classpathWithoutJavaFXJars = execTask.getClasspath().filter(
jar -> Arrays.stream(JavaFXModule.values()).noneMatch(javaFXModule -> jar.getName().contains(javaFXModule.getArtifactName()))
);
final FileCollection javaFXPlatformJars = execTask.getClasspath().filter(jar -> isJavaFXJar(jar, javaFXOptions.getPlatform()));
if (moduleOptions != null) {
LOGGER.info("Modular JavaFX application found");
// Remove empty JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
definedJavaFXModuleNames.forEach(javaFXModule -> moduleOptions.getAddModules().add(javaFXModule));
} else {
LOGGER.info("Non-modular JavaFX application found");
// Remove all JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars);
var javaFXModuleJvmArgs = List.of("--module-path", javaFXPlatformJars.getAsPath());
var jvmArgs = new ArrayList<String>();
jvmArgs.add("--add-modules");
jvmArgs.add(String.join(",", definedJavaFXModuleNames));
List<String> execJvmArgs = execTask.getJvmArgs();
if (execJvmArgs != null) {
jvmArgs.addAll(execJvmArgs);
}
jvmArgs.addAll(javaFXModuleJvmArgs);
execTask.setJvmArgs(jvmArgs);
}
}
} else {
throw new GradleException("Run task not found. Please, make sure the Application plugin is applied");

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.1.3"
GIT_TAG="2.3.0"
```
The project can then be initially cloned as follows:

2
drongo

@ -1 +1 @@
Subproject commit abb598d3b041a9d0b3d0ba41b5fb9785e2100193
Subproject commit e975cbe6f8d8574785124e6db5780d0541e20024

Binary file not shown.

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

15
gradlew vendored
View file

@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -112,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@ -170,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@ -203,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

25
gradlew.bat vendored
View file

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

2
lark

@ -1 +1 @@
Subproject commit 5facb25ede49c30650a8460dc04982650edb397f
Subproject commit 10e8d9cd4bbe9fde4dd93c059e2a9faeec6be3e0

48
repackage.sh Executable file
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"

View file

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.0</string>
<string>2.3.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@ -33,6 +33,8 @@
<string>Copyright (C) 2021</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSCameraUseContinuityCameraDeviceType</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Sparrow requires access to the camera in order to scan QR codes</string>
<key>NSLocalNetworkUsageDescription</key>

View file

@ -3,13 +3,14 @@ package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.psbt.PSBTSignatureException;
import com.sparrowwallet.drongo.psbt.*;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
@ -30,7 +31,7 @@ import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.WalletController;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import de.codecentric.centerdevice.MenuToolkit;
import de.jangassen.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
@ -49,12 +50,14 @@ import javafx.geometry.Side;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.control.Label;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.stage.*;
import javafx.stage.Window;
import javafx.util.Duration;
import org.controlsfx.control.Notifications;
import org.controlsfx.control.StatusBar;
@ -69,6 +72,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.ParseException;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
@ -822,10 +826,10 @@ public class AppController implements Initializable {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
if(asText) {
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
writer.print(transactionTabData.getPsbt().getForExport().toBase64String(includeXpubs));
writer.flush();
} else {
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs, true));
outputStream.write(transactionTabData.getPsbt().getForExport().serialize(includeXpubs, true));
}
} catch(IOException e) {
log.error("Error saving PSBT", e);
@ -848,7 +852,7 @@ public class AppController implements Initializable {
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
String data = asBase64 ? transactionTabData.getPsbt().toBase64String() : transactionTabData.getPsbt().toString();
String data = asBase64 ? transactionTabData.getPsbt().getForExport().toBase64String() : transactionTabData.getPsbt().getForExport().toString();
ClipboardContent content = new ClipboardContent();
content.putString(data);
@ -862,7 +866,7 @@ public class AppController implements Initializable {
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
byte[] psbtBytes = transactionTabData.getPsbt().serialize();
byte[] psbtBytes = transactionTabData.getPsbt().getForExport().serialize();
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, false);
@ -1034,6 +1038,10 @@ public class AppController implements Initializable {
cmd.add(System.getProperty(JPACKAGE_APP_PATH));
cmd.addAll(args.toParams());
final ProcessBuilder builder = new ProcessBuilder(cmd);
if(OsType.getCurrent() == OsType.UNIX) {
Map<String, String> env = builder.environment();
env.remove("LD_LIBRARY_PATH");
}
builder.start();
quit(event);
} catch(Exception e) {
@ -1422,6 +1430,10 @@ public class AppController implements Initializable {
}
public void sendToMany(ActionEvent event) {
sendToMany(Collections.emptyList());
}
private void sendToMany(List<Payment> initialPayments) {
if(sendToManyDialog != null) {
Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow();
stage.setAlwaysOnTop(true);
@ -1437,7 +1449,7 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit();
}
sendToManyDialog = new SendToManyDialog(bitcoinUnit);
sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
@ -1889,6 +1901,11 @@ public class AppController implements Initializable {
}
private void addTransactionTab(String name, File file, PSBT psbt) {
//Convert to PSBTv0 first
if(psbt.getVersion() != null && psbt.getVersion() >= 2) {
psbt.convertVersion(0);
}
//Add any missing previous outputs if available in open wallets
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(psbtInput.getUtxo() == null) {
@ -1908,6 +1925,39 @@ public class AppController implements Initializable {
}
}
//Add DNS payment information if not already cached
for(PSBTOutput psbtOutput : psbt.getPsbtOutputs()) {
if(psbtOutput.getDnssecProof() != null && !psbtOutput.getDnssecProof().isEmpty()) {
Address address = psbtOutput.getScript() != null ? psbtOutput.getScript().getToAddress() : null;
if(address != null && DnsPaymentCache.getDnsPayment(address) == null) {
try {
Optional<DnsPayment> optDnsPayment = psbtOutput.getDnsPayment();
if(optDnsPayment.isPresent() && address.equals(optDnsPayment.get().bitcoinURI().getAddress())) {
DnsPaymentCache.putDnsPayment(address, optDnsPayment.get());
}
} catch(Exception e) {
log.debug("Error resolving DNS payment", e);
}
}
SilentPaymentAddress silentPaymentAddress = psbtOutput.getSilentPaymentAddress();
if(address != null && silentPaymentAddress == null) {
silentPaymentAddress = AppServices.get().getOpenWallets().keySet().stream()
.map(wallet -> wallet.getSilentPaymentAddress(address)).filter(Objects::nonNull).findFirst().orElse(null);
}
if(silentPaymentAddress != null && DnsPaymentCache.getDnsPayment(silentPaymentAddress) == null) {
try {
Optional<DnsPayment> optDnsPayment = psbtOutput.getDnsPayment();
if(optDnsPayment.isPresent() && silentPaymentAddress.equals(optDnsPayment.get().bitcoinURI().getSilentPaymentAddress())) {
DnsPaymentCache.putDnsPayment(silentPaymentAddress, optDnsPayment.get());
}
} catch(Exception e) {
log.debug("Error resolving DNS payment", e);
}
}
}
}
Window psbtWalletWindow = AppServices.get().getWindowForPSBT(psbt);
if(psbtWalletWindow != null && !tabs.getScene().getWindow().equals(psbtWalletWindow)) {
EventManager.get().post(new ViewPSBTEvent(psbtWalletWindow, name, file, psbt));
@ -2046,23 +2096,33 @@ public class AppController implements Initializable {
}
MenuItem moveRight = new MenuItem("Move Right");
moveRight.setAccelerator(new KeyCodeCombination(KeyCode.RIGHT, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN));
moveRight.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
if(currentIndex + 1 >= tabs.getTabs().size()) {
return;
}
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index + 1, tab);
tabs.getTabs().remove(selectedTab);
tabs.getTabs().add(currentIndex + 1, selectedTab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
tabs.getSelectionModel().select(selectedTab);
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
});
MenuItem moveLeft = new MenuItem("Move Left");
moveLeft.setAccelerator(new KeyCodeCombination(KeyCode.LEFT, KeyCombination.CONTROL_DOWN, KeyCombination.SHIFT_DOWN));
moveLeft.setOnAction(event -> {
int index = tabs.getTabs().indexOf(tab);
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
if(currentIndex == 0) {
return;
}
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
tabs.getTabs().removeListener(tabsChangeListener);
tabs.getTabs().remove(tab);
tabs.getTabs().add(index - 1, tab);
tabs.getTabs().remove(selectedTab);
tabs.getTabs().add(currentIndex - 1, selectedTab);
tabs.getTabs().addListener(tabsChangeListener);
tabs.getSelectionModel().select(tab);
tabs.getSelectionModel().select(selectedTab);
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
});
contextMenu.getItems().addAll(moveRight, moveLeft);
@ -3107,6 +3167,11 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void requestSendToMany(RequestSendToManyEvent event) {
sendToMany(event.getPayments());
}
@Subscribe
public void functionAction(FunctionActionEvent event) {
selectTab(event.getWallet());

View file

@ -91,8 +91,7 @@ public class AppServices {
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
public static final List<Long> LONG_FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L, 2048L, 4096L, 8192L);
public static final List<Long> FEE_RATES_RANGE = LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
private static final List<Double> LONG_FEE_RATES_RANGE = List.of(1d, 2d, 4d, 8d, 16d, 32d, 64d, 128d, 256d, 512d, 1024d, 2048d, 4096d, 8192d);
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
@ -136,10 +135,14 @@ public class AppServices {
private static Map<Integer, Double> targetBlockFeeRates;
private static Double nextBlockMedianFeeRate;
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
private static Double minimumRelayFeeRate;
private static Double serverMinimumRelayFeeRate;
private static CurrencyRate fiatCurrencyExchangeRate;
private static List<Device> devices;
@ -209,6 +212,7 @@ public class AppServices {
preventSleepService = createPreventSleepService();
onlineProperty.addListener(onlineServicesListener);
minimumRelayFeeRate = getConfiguredMinimumRelayFeeRate(config);
if(config.getMode() == Mode.ONLINE) {
if(config.requiresInternalTor()) {
@ -748,6 +752,30 @@ public class AppServices {
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
public static List<Double> getLongFeeRatesRange() {
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return LONG_FEE_RATES_RANGE;
} else {
List<Double> longFeeRatesRange = new ArrayList<>();
longFeeRatesRange.add(minimumRelayFeeRate);
longFeeRatesRange.addAll(LONG_FEE_RATES_RANGE);
return longFeeRatesRange;
}
}
public static List<Double> getFeeRatesRange() {
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
} else {
List<Double> longFeeRatesRange = getLongFeeRatesRange();
return longFeeRatesRange.subList(0, longFeeRatesRange.size() - 4);
}
}
public static Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate;
}
public static double getFallbackFeeRate() {
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
}
@ -782,10 +810,18 @@ public class AppServices {
});
}
public static Double getConfiguredMinimumRelayFeeRate(Config config) {
return config.getMinRelayFeeRate() >= 0d && config.getMinRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE ? config.getMinRelayFeeRate() : null;
}
public static Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
}
public static Double getServerMinimumRelayFeeRate() {
return serverMinimumRelayFeeRate;
}
public static CurrencyRate getFiatCurrencyExchangeRate() {
return fiatCurrencyExchangeRate;
}
@ -799,8 +835,8 @@ public class AppServices {
}
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getPayjoinUrl() == null) {
throw new IllegalArgumentException("Not a payjoin URI");
if(bitcoinURI.getPayjoinUrl() == null || bitcoinURI.getAddress() == null) {
throw new IllegalArgumentException("Not a valid payjoin URI");
}
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
@ -1197,7 +1233,7 @@ public class AppServices {
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
return Font.font("Fragment Mono Regular", 13);
}
public static boolean isOnWayland() {
@ -1213,7 +1249,10 @@ public class AppServices {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
if(getConfiguredMinimumRelayFeeRate(Config.get()) == null) {
minimumRelayFeeRate = event.getMinimumRelayFeeRate() == null ? Transaction.DEFAULT_MIN_RELAY_FEE : event.getMinimumRelayFeeRate();
}
serverMinimumRelayFeeRate = event.getMinimumRelayFeeRate();
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
@ -1249,11 +1288,13 @@ public class AppServices {
if(AppServices.currentBlockHeight != null) {
blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5);
}
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
}
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
}
@Subscribe

View file

@ -113,8 +113,8 @@ public class SparrowDesktop extends Application {
private void initializeFonts() {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Italic.ttf"), 11);
if(OsType.getCurrent() == OsType.MACOS) {
Font.loadFont(AppServices.class.getResourceAsStream("/font/LiberationSans-Regular.ttf"), 13);
}

View file

@ -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.2.0";
public static final String APP_VERSION = "2.3.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";

View file

@ -3,6 +3,8 @@ 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;
@ -15,6 +17,7 @@ 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;
@ -26,12 +29,13 @@ public class BlockCube extends Group {
public static final double CUBE_SIZE = 60;
private final IntegerProperty weightProperty = new SimpleIntegerProperty(0);
private final DoubleProperty medianFeeProperty = new SimpleDoubleProperty(-1.0d);
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;
@ -43,21 +47,27 @@ public class BlockCube extends Group {
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);
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
this.feeRatesSource.set(feeRatesSource);
this.weightProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeProperty.addListener((_, _, newValue) -> {
medianFeeText.setText("~" + Math.round(Math.max(newValue.doubleValue(), 1.0d)));
unitsText.setText(" s/vb");
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 - (medianFeeText.getLayoutBounds().getWidth() + unitsWidth)) / 2);
medianFeeTextFlow.setTranslateX((CUBE_SIZE - (medianFeeWidth + unitsWidth)) / 2);
});
this.txCountProperty.addListener((_, _, newValue) -> {
txCountText.setText(newValue.intValue() == 0 ? "" : newValue + " txes");
@ -79,6 +89,11 @@ public class BlockCube extends Group {
updateFill();
}
});
this.feeRatesSource.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeText.textProperty().addListener((_, _, _) -> {
pulse();
});
@ -145,12 +160,15 @@ public class BlockCube extends Group {
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, elapsedText);
getChildren().addAll(frontFaceGroup, top, left, heightText, medianFeeTextFlow, txCountText, feeRateIcon, elapsedText);
}
private void updateFill() {
@ -167,6 +185,7 @@ public class BlockCube extends Group {
usedArea.setHeight(CUBE_SIZE - startYAbsolute);
usedArea.setVisible(true);
heightText.setVisible(true);
feeRateIcon.getChildren().clear();
} else {
getStyleClass().removeAll("block-confirmed");
if(!getStyleClass().contains("block-unconfirmed")) {
@ -175,6 +194,16 @@ public class BlockCube extends Group {
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();
}
} else {
feeRateIcon.getChildren().clear();
}
}
}
@ -324,8 +353,20 @@ public class BlockCube extends Group {
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(),
return new BlockCube(blockSummary.getWeight().orElse(0), blockSummary.getMedianFee().orElse(-1.0d), blockSummary.getHeight(),
blockSummary.getTransactionCount().orElse(0), blockSummary.getTimestamp().getTime(), true);
}
}

View file

@ -87,6 +87,8 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
} else if(entry instanceof UtxoEntry) {
setGraphic(null);
} else if(entry instanceof HashIndexEntry) {
tooltip.hideConfirmations();
Region node = new Region();
node.setPrefWidth(10);
setGraphic(node);
@ -148,6 +150,14 @@ class CoinCell extends TreeTableCell<Entry, Number> implements ConfirmationsList
setTooltipText();
}
public void hideConfirmations() {
showConfirmations = false;
isCoinbase = false;
confirmationsProperty.unbind();
setTooltipText();
}
private void setTooltipText() {
setText(value + (showConfirmations ? " (" + getConfirmationsDescription() + ")" : ""));
}

View file

@ -6,10 +6,16 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.controlsfx.control.textfield.CustomTextField;
import java.util.List;
public class ComboBoxTextField extends CustomTextField {
private final ObjectProperty<ComboBox<?>> comboProperty = new SimpleObjectProperty<>();
@ -68,4 +74,53 @@ public class ComboBoxTextField extends CustomTextField {
public void setComboProperty(ComboBox<?> comboProperty) {
this.comboProperty.set(comboProperty);
}
public ContextMenu getCustomContextMenu(List<MenuItem> customItems) {
return new CustomContextMenu(customItems);
}
public class CustomContextMenu extends ContextMenu {
public CustomContextMenu(List<MenuItem> customItems) {
super();
setFont(null);
MenuItem undo = new MenuItem("Undo");
undo.setOnAction(_ -> undo());
MenuItem redo = new MenuItem("Redo");
redo.setOnAction(_ -> redo());
MenuItem cut = new MenuItem("Cut");
cut.setOnAction(_ -> cut());
MenuItem copy = new MenuItem("Copy");
copy.setOnAction(_ -> copy());
MenuItem paste = new MenuItem("Paste");
paste.setOnAction(_ -> paste());
MenuItem delete = new MenuItem("Delete");
delete.setOnAction(_ -> deleteText(getSelection()));
MenuItem selectAll = new MenuItem("Select All");
selectAll.setOnAction(_ -> selectAll());
getItems().addAll(undo, redo, new SeparatorMenuItem(), cut, copy, paste, delete, new SeparatorMenuItem(), selectAll);
getItems().addAll(customItems);
setOnShowing(_ -> {
boolean hasSelection = getSelection().getLength() > 0;
boolean hasText = getText() != null && !getText().isEmpty();
boolean clipboardHasContent = Clipboard.getSystemClipboard().hasString();
undo.setDisable(!isUndoable());
redo.setDisable(!isRedoable());
cut.setDisable(!isEditable() || !hasSelection);
copy.setDisable(!hasSelection);
paste.setDisable(!isEditable() || !clipboardHasContent);
delete.setDisable(!hasSelection);
selectAll.setDisable(!hasText);
});
}
}
}

View file

@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import static com.sparrowwallet.sparrow.AppServices.getActiveWindow;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
public class ConfirmationAlert extends Alert {
private final CheckBox dontAskAgain;
public ConfirmationAlert(String title, String contentText, ButtonType... buttons) {
super(AlertType.CONFIRMATION, contentText, buttons);
initOwner(getActiveWindow());
setStageIcon(getDialogPane().getScene().getWindow());
getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
setTitle(title);
setHeaderText(title);
VBox contentBox = new VBox(20);
contentBox.setPadding(new Insets(10, 20, 10, 20));
Label contentLabel = new Label(contentText);
contentLabel.setWrapText(true);
dontAskAgain = new CheckBox("Don't ask again");
contentBox.getChildren().addAll(contentLabel, dontAskAgain);
getDialogPane().setContent(contentBox);
}
public boolean isDontAskAgain() {
return dontAskAgain.isSelected();
}
}

View file

@ -11,6 +11,7 @@ import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.event.EventHandler;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
public class CopyableCoinLabel extends CopyableLabel {
@ -29,6 +30,10 @@ public class CopyableCoinLabel extends CopyableLabel {
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
setOnMouseClicked(event -> {
if(!event.getButton().equals(MouseButton.PRIMARY)) {
return;
}
if(bitcoinUnit == null) {
bitcoinUnit = Config.get().getBitcoinUnit();
}

View file

@ -453,20 +453,26 @@ public class DevicePane extends TitledDescriptionPane {
});
vBox.getChildren().addAll(pinField, enterPinButton);
TilePane tilePane = new TilePane();
tilePane.setPrefColumns(3);
tilePane.setHgap(10);
tilePane.setVgap(10);
tilePane.setMaxWidth(150);
tilePane.setMaxHeight(120);
GridPane gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setMaxWidth(150);
gridPane.setMaxHeight(device.getModel().hasZeroInPin() ? 160 : 120);
int[] digits = new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3};
int[] digits = device.getModel().hasZeroInPin() ? new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3, 0} : new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3};
for(int i = 0; i < digits.length; i++) {
Button pinButton = new Button();
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, "CIRCLE");
pinButton.setGraphic(circle);
pinButton.setUserData(digits[i]);
tilePane.getChildren().add(pinButton);
GridPane.setRowIndex(pinButton, i / 3);
GridPane.setColumnIndex(pinButton, i % 3);
if((i / 3) == 3) {
GridPane.setHgrow(pinButton, Priority.ALWAYS);
GridPane.setColumnSpan(pinButton, 3);
pinButton.setMaxWidth(Double.MAX_VALUE);
}
gridPane.getChildren().add(pinButton);
pinButton.setOnAction(event -> {
pinField.setText(pinField.getText() + pinButton.getUserData());
});
@ -474,7 +480,7 @@ public class DevicePane extends TitledDescriptionPane {
HBox contentBox = new HBox();
contentBox.setSpacing(50);
contentBox.getChildren().add(tilePane);
contentBox.getChildren().add(gridPane);
contentBox.getChildren().add(vBox);
contentBox.setPadding(new Insets(10, 0, 10, 0));
contentBox.setAlignment(Pos.TOP_CENTER);

View file

@ -5,6 +5,8 @@ import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@ -55,7 +57,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
super.updateItem(entry, empty);
//Return immediately to avoid CPU usage when updating the same invisible cell to determine tableview size (see https://bugs.openjdk.org/browse/JDK-8280442)
if(this == lastCell && !getTableRow().isVisible()) {
if(this == lastCell && !getTableRow().isVisible() && isTableSizeRecalculation()) {
return;
}
lastCell = this;
@ -66,8 +68,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
setText(null);
setGraphic(null);
} else {
if(entry instanceof TransactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(entry instanceof TransactionEntry transactionEntry) {
if(transactionEntry.getBlockTransaction().getHeight() == -1) {
setText("Unconfirmed Parent");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
@ -101,7 +102,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) &&
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction, transactionEntry.getWallet()) &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
@ -121,8 +122,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
setGraphic(actionBox);
} else if(entry instanceof NodeEntry) {
NodeEntry nodeEntry = (NodeEntry)entry;
} else if(entry instanceof NodeEntry nodeEntry) {
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
@ -163,8 +163,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
setContextMenu(null);
setGraphic(new HBox());
}
} else if(entry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
setText(hashIndexEntry.getDescription());
setContextMenu(getTreeTableView().getStyleClass().contains("bip47") ? null : new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry));
Tooltip tooltip = new Tooltip();
@ -212,13 +211,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
boolean silentPaymentTransaction = transactionEntry.getWallet().isSilentPaymentsTransaction(blockTransaction);
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled())
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled() || silentPaymentTransaction)
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
@ -243,6 +243,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
.collect(Collectors.toList());
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
boolean safeToAddInputsOrOutputs = transactionEntry.getWallet().isSafeToAddInputsOrOutputs(blockTransaction);
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
double vSize = tx.getVirtualSize();
@ -257,7 +258,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction) {
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction && safeToAddInputsOrOutputs) {
//If there is insufficient change output, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
@ -298,9 +299,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
label += " (Replaced By Fee)";
}
if(txOutput.getScript().getToAddress() != null) {
Address address = txOutput.getScript().getToAddress();
if(address != null) {
long value = txOutput.getValue();
//Disable change creation by enabling max payment when there is only one output and no additional UTXOs included
return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0);
boolean sendMax = blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0;
SilentPaymentAddress silentPaymentAddress = transactionEntry.getWallet().getSilentPaymentAddress(address);
return silentPaymentAddress == null ? new Payment(address, label, value, sendMax) : new SilentPayment(silentPaymentAddress, label, value, sendMax);
}
return null;
@ -337,7 +342,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction, safeToAddInputsOrOutputs)));
}
private static Double getMaxFeeRate() {
@ -394,11 +399,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
Payment payment = new Payment(freshAddress, label, inputTotal, true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null, true)));
}
private static boolean canRBF(BlockTransaction blockTransaction) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
private static boolean canRBF(BlockTransaction blockTransaction, Wallet wallet) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee() || wallet.isSilentPaymentsTransaction(blockTransaction);
}
private static boolean canSignMessage(WalletNode walletNode) {
@ -476,7 +481,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
}
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled");
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction(), transactionEntry.getWallet()) ? "Enabled" : "Disabled");
}
return tooltip;
@ -544,6 +549,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
Wallet wallet = transactionEntry.getWallet();
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@ -553,7 +559,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
});
getItems().add(viewTransaction);
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
@ -564,7 +570,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
getItems().add(increaseFee);
}
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> {
@ -850,4 +856,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
}
}
private boolean isTableSizeRecalculation() {
//As per https://bugs.openjdk.org/browse/JDK-8265669 we check for cell visibility to avoid unnecessary recalculation, but this can result in false positives
//The method releaseCell in VirtualFlow is responsible for setting accumCell visibility to false after use, so check this method is calling updateItem
return StackWalker.getInstance().walk(frames -> frames.anyMatch(frame -> frame.getClassName().equals("javafx.scene.control.skin.VirtualFlow")
&& frame.getMethodName().equals("releaseCell")));
}
}

View file

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.application.Platform;
@ -7,6 +8,7 @@ import javafx.scene.Node;
import javafx.scene.control.Slider;
import javafx.util.StringConverter;
import java.text.DecimalFormat;
import java.util.*;
import java.util.stream.Collectors;
@ -14,9 +16,11 @@ import static com.sparrowwallet.sparrow.AppServices.*;
public class FeeRangeSlider extends Slider {
private static final double FEE_RATE_SCROLL_INCREMENT = 0.01;
private static final DecimalFormat INTEGER_FEE_RATE_FORMAT = new DecimalFormat("0");
private static final DecimalFormat FRACTIONAL_FEE_RATE_FORMAT = new DecimalFormat("0.###");
public FeeRangeSlider() {
super(0, FEE_RATES_RANGE.size() - 1, 0);
super(0, AppServices.getFeeRatesRange().size() - 1, 0);
setMajorTickUnit(1);
setMinorTickCount(0);
setSnapToTicks(false);
@ -27,11 +31,11 @@ public class FeeRangeSlider extends Slider {
setLabelFormatter(new StringConverter<>() {
@Override
public String toString(Double object) {
Long feeRate = LONG_FEE_RATES_RANGE.get(object.intValue());
Double feeRate = AppServices.getLongFeeRatesRange().get(object.intValue());
if(isLongFeeRange() && feeRate >= 1000) {
return feeRate / 1000 + "k";
return INTEGER_FEE_RATE_FORMAT.format(feeRate / 1000) + "k";
}
return Long.toString(feeRate);
return feeRate > 0d && feeRate < Transaction.DEFAULT_MIN_RELAY_FEE ? FRACTIONAL_FEE_RATE_FORMAT.format(feeRate) : INTEGER_FEE_RATE_FORMAT.format(feeRate);
}
@Override
@ -51,10 +55,10 @@ public class FeeRangeSlider extends Slider {
setOnScroll(event -> {
if(event.getDeltaY() != 0) {
double newFeeRate = getFeeRate() + (event.getDeltaY() > 0 ? FEE_RATE_SCROLL_INCREMENT : -FEE_RATE_SCROLL_INCREMENT);
if(newFeeRate < LONG_FEE_RATES_RANGE.get(0)) {
newFeeRate = LONG_FEE_RATES_RANGE.get(0);
} else if(newFeeRate > LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1)) {
newFeeRate = LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1);
if(newFeeRate < AppServices.getLongFeeRatesRange().getFirst()) {
newFeeRate = AppServices.getLongFeeRatesRange().getFirst();
} else if(newFeeRate > AppServices.getLongFeeRatesRange().getLast()) {
newFeeRate = AppServices.getLongFeeRatesRange().getLast();
}
setFeeRate(newFeeRate);
}
@ -62,27 +66,79 @@ public class FeeRangeSlider extends Slider {
}
public double getFeeRate() {
return getFeeRate(AppServices.getMinimumRelayFeeRate());
}
public double getFeeRate(Double minRelayFeeRate) {
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return Math.pow(2.0, getValue());
}
if(getValue() < 1.0d) {
if(minRelayFeeRate == 0.0d) {
return getValue();
}
return Math.pow(minRelayFeeRate, 1.0d - getValue());
}
return Math.pow(2.0, getValue() - 1.0d);
}
public void setFeeRate(double feeRate) {
double value = Math.log(feeRate) / Math.log(2);
setFeeRate(feeRate, AppServices.getMinimumRelayFeeRate());
}
public void setFeeRate(double feeRate, Double minRelayFeeRate) {
double value = getValue(feeRate, minRelayFeeRate);
updateMaxFeeRange(value);
setValue(value);
}
private double getValue(double feeRate, Double minRelayFeeRate) {
double value;
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
value = Math.log(feeRate) / Math.log(2);
} else {
if(feeRate < Transaction.DEFAULT_MIN_RELAY_FEE) {
if(minRelayFeeRate == 0.0d) {
return feeRate;
}
value = 1.0d - (Math.log(feeRate) / Math.log(minRelayFeeRate));
} else {
value = (Math.log(feeRate) / Math.log(2.0)) + 1.0d;
}
}
return value;
}
public void updateFeeRange(Double minRelayFeeRate, Double previousMinRelayFeeRate) {
if(minRelayFeeRate != null && previousMinRelayFeeRate != null) {
setFeeRate(getFeeRate(previousMinRelayFeeRate), minRelayFeeRate);
}
setMinorTickCount(1);
setMinorTickCount(0);
}
private void updateMaxFeeRange(double value) {
if(value >= getMax() && !isLongFeeRange()) {
setMax(LONG_FEE_RATES_RANGE.size() - 1);
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
setMin(1.0d);
}
setMax(AppServices.getLongFeeRatesRange().size() - 1);
updateTrackHighlight();
} else if(value == getMin() && isLongFeeRange()) {
setMax(FEE_RATES_RANGE.size() - 1);
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
setMin(0.0d);
}
setMax(AppServices.getFeeRatesRange().size() - 1);
updateTrackHighlight();
}
}
private boolean isLongFeeRange() {
return getMax() > FEE_RATES_RANGE.size() - 1;
public boolean isLongFeeRange() {
return getMax() > AppServices.getFeeRatesRange().size() - 1;
}
public void updateTrackHighlight() {
@ -137,9 +193,9 @@ public class FeeRangeSlider extends Slider {
}
private int getPercentageOfFeeRange(Double feeRate) {
double index = Math.log(feeRate) / Math.log(2);
double index = getValue(feeRate, AppServices.getMinimumRelayFeeRate());
if(isLongFeeRange()) {
index *= ((double)FEE_RATES_RANGE.size() / (LONG_FEE_RATES_RANGE.size())) * 0.99;
index *= ((double)AppServices.getFeeRatesRange().size() / (AppServices.getLongFeeRatesRange().size())) * 0.99;
}
return (int)Math.round(index * 10.0);
}

View file

@ -240,6 +240,9 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
setFormatFromScriptType(address.getScriptType());
if(wallet != null) {
setWalletNodeFromAddress(wallet, address);
if(walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
}
}
} catch(InvalidAddressException e) {
//can't happen
@ -273,7 +276,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
if(wallet != null && walletNode != null) {
setFormatFromScriptType(wallet.getScriptType());
setFormatFromScriptType(getSigningScriptType(walletNode));
} else {
formatGroup.selectToggle(formatElectrum);
}
@ -287,9 +290,13 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
private boolean canSign(Wallet wallet) {
return wallet.getKeystores().get(0).hasPrivateKey()
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().get(0).getWalletModel().isCard();
return wallet.getKeystores().getFirst().hasPrivateKey()
|| wallet.getKeystores().getFirst().getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().getFirst().getWalletModel().isCard();
}
private boolean canSignBip322(Wallet wallet) {
return wallet.getKeystores().getFirst().hasPrivateKey();
}
private Address getAddress()throws InvalidAddressException {
@ -313,6 +320,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address);
}
private ScriptType getSigningScriptType(WalletNode walletNode) {
ScriptType scriptType = walletNode.getWallet().getScriptType();
return canSign(walletNode.getWallet()) && !canSignBip322(walletNode.getWallet()) ? ScriptType.P2PKH : scriptType;
}
private void setFormatFromScriptType(ScriptType scriptType) {
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
@ -345,7 +357,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//Note we can expect a single keystore due to the check in the constructor
Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
if(signingWallet.getKeystores().getFirst().hasPrivateKey()) {
if(signingWallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
@ -358,7 +370,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void signUnencryptedKeystore(Wallet decryptedWallet) {
try {
Keystore keystore = decryptedWallet.getKeystores().get(0);
Keystore keystore = decryptedWallet.getKeystores().getFirst();
ECKey privKey = keystore.getKey(walletNode);
String signatureText;
if(isBip322()) {
@ -378,8 +390,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
List<String> fingerprints = List.of(deviceWallet.getKeystores().getFirst().getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().getFirst().getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();

View file

@ -398,14 +398,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
double feeRate = feeRange.getFeeRate();
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
if(feeRate == AppServices.getMinimumRelayFeeRate() && feeRate > 0d) {
fee++;
}
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
if(total - fee <= dustThreshold) {
feeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + 1;
feeRate = AppServices.getMinimumRelayFeeRate();
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + (feeRate > 0d ? 1 : 0);
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");

View file

@ -122,19 +122,21 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0));
}
});
if(opening) {
webcamService.openedProperty().addListener((_, _, opened) -> {
if(opened) {
Platform.runLater(() -> {
try {
postOpenUpdate = true;
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getDevices());
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getAvailableDevices());
newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !webcamService.getDevices().contains(device));
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
if(webcamService.getDevice() != null) {
for(CaptureDevice device : foundDevices) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
if(device.equals(webcamService.getDevice())) {
webcamDeviceProperty.set(device);
}
}
@ -146,10 +148,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
postOpenUpdate = false;
}
});
}
});
webcamService.closedProperty().addListener((_, _, closed) -> {
if(closed && webcamResolutionProperty.get() != null) {
} else if(webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> {
@ -190,6 +189,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
});
webcamDeviceProperty.addListener((_, _, newValue) -> {
Config.get().setWebcamDevice(newValue.getName());
Config.get().setWebcamDeviceId(newValue.getUniqueId());
if(!Objects.equals(webcamService.getDevice(), newValue)) {
webcamService.cancel();
}

View file

@ -1,12 +1,15 @@
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;
@ -14,8 +17,12 @@ import javafx.util.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.sparrowwallet.sparrow.AppServices.TARGET_BLOCKS_RANGE;
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;
@ -24,6 +31,7 @@ public class RecentBlocksView extends Pane {
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) -> {
@ -41,6 +49,18 @@ public class RecentBlocksView extends Pane {
cube.setElapsed(BlockCube.getElapsed(cube.getTimestamp()));
}
}));
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
updateFeeRatesSource(feeRatesSource);
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() {
@ -54,7 +74,7 @@ public class RecentBlocksView extends Pane {
}
private void createSeparator() {
Line separator = new Line(SEPARATOR_X, -9, SEPARATOR_X, 80);
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);
@ -73,14 +93,14 @@ public class RecentBlocksView extends Pane {
if(latestTip > knownTip) {
addNewBlock(latestBlocks, currentFeeRate);
} else {
for(int i = 1; i < getCubes().size() && i < latestBlocks.size(); i++) {
for(int i = 1; i < getCubes().size() && i <= latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.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(0.0d));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(-1.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
updateFeeRate(currentFeeRate);
@ -88,7 +108,7 @@ public class RecentBlocksView extends Pane {
}
}
public void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
private void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
return;
}
@ -100,7 +120,7 @@ public class RecentBlocksView extends Pane {
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(0.0d));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(-1.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
@ -120,6 +140,14 @@ public class RecentBlocksView extends Pane {
}
}
public void updateFeeRate(Map<Integer, Double> targetBlockFeeRates) {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
if(targetBlockFeeRates.get(defaultTarget) != null) {
Double defaultRate = targetBlockFeeRates.get(defaultTarget);
updateFeeRate(defaultRate);
}
}
public void updateFeeRate(Double currentFeeRate) {
if(!getCubes().isEmpty()) {
BlockCube firstCube = getCubes().getFirst();

View file

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.drongo.protocol.ScriptOpCodes;
import javafx.geometry.Pos;
import org.controlsfx.control.decoration.Decorator;
import org.controlsfx.control.decoration.GraphicDecoration;
@ -53,7 +54,11 @@ public class ScriptArea extends CodeArea {
for (int i = 0; i < script.getChunks().size(); i++) {
ScriptChunk chunk = script.getChunks().get(i);
if(chunk.isOpCode()) {
if(chunk.getOpcode() == ScriptOpCodes.OP_0 && witnessScript != null) {
append("<empty>", "script-other");
} else {
append(chunk.toString(), "script-opcode");
}
} else if(chunk.isPubKey()) {
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
} else if(chunk.isSignature()) {

View file

@ -5,36 +5,49 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.RequestConnectEvent;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
private final SpreadsheetView spreadsheetView;
public static final AddressCellType ADDRESS = new AddressCellType();
public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit) {
public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
this.bitcoinUnit = bitcoinUnit;
final DialogPane dialogPane = new SendToManyDialogPane();
@ -44,7 +57,8 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary.");
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());
List<Payment> initialPayments = IntStream.range(0, 100)
.mapToObj(i -> i < payments.size() ? payments.get(i) : new Payment(null, null, -1, false)).collect(Collectors.toList());
Grid grid = getGrid(initialPayments);
spreadsheetView = new SpreadsheetView(grid) {
@ -69,14 +83,16 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setContent(stackPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okButton = (Button) dialogPane.lookupButton(ButtonType.OK);
okButton.addEventFilter(ActionEvent.ACTION, event -> {
getPayments();
event.consume();
});
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load CSV", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getPayments() : null;
});
setResultConverter((_) -> null);
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
@ -86,18 +102,24 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
private Grid getGrid(List<Payment> payments) {
int rowCount = payments.size();
return createGrid(payments.stream().map(payment -> new SendToPayment(payment, SendToAddress.fromPayment(payment))).collect(Collectors.toList()));
}
private Grid createGrid(List<SendToPayment> sendToPayments) {
int rowCount = sendToPayments.size();
int columnCount = 3;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
for(int row = 0; row < grid.getRowCount(); ++row) {
SendToPayment sendToPayment = sendToPayments.get(row);
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
SpreadsheetCell addressCell = ADDRESS.createCell(row, 0, 1, 1, payments.get(row).getAddress());
SendToAddress sendToAddress = sendToPayment.sendToAddress();
SpreadsheetCell addressCell = SEND_TO_ADDRESS.createCell(row, 0, 1, 1, sendToAddress);
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
double amount = (double)payments.get(row).getAmount();
double amount = (double)sendToPayment.payment().getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
@ -109,7 +131,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
list.add(amountCell);
list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, payments.get(row).getLabel()));
list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, sendToPayment.payment().getLabel()));
rows.add(list);
}
grid.setRows(rows);
@ -118,32 +140,49 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return grid;
}
private List<Payment> getPayments() {
List<Payment> payments = new ArrayList<>();
Grid grid = spreadsheetView.getGrid();
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
private void getPayments() {
if(needsResolution() && Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) {
if(Config.get().getConnectToResolve() == null || Config.get().getConnectToResolve() == Boolean.FALSE) {
Platform.runLater(() -> {
ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to resolve?", "You are currently offline. Connect to resolve the addresses?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = confirmationAlert.showAndWait();
if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
Config.get().setConnectToResolve(optType.get() == ButtonType.YES);
}
if(optType.isPresent() && optType.get() == ButtonType.YES) {
EventManager.get().post(new RequestConnectEvent());
}
});
} else {
Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent()));
}
return;
}
CreatePaymentsService createPaymentsService = new CreatePaymentsService();
createPaymentsService.setOnSucceeded(_ -> {
List<Payment> payments = createPaymentsService.getValue();
if(payments != null) {
setResult(payments);
}
});
createPaymentsService.setOnFailed(event -> {
Throwable ex = event.getSource().getException();
AppServices.showErrorDialog("Error creating payments", ex.getMessage());
});
createPaymentsService.start();
}
private boolean needsResolution() {
for(int row = 0; row < spreadsheetView.getGrid().getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
Address address = (Address)rowCells.get(0).getItem();
Double value = (Double)rowCells.get(1).getItem();
String label = (String)rowCells.get(2).getItem();
if(firstLabel == null) {
firstLabel = label;
}
if(label == null || label.isEmpty()) {
label = firstLabel;
}
if(address != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(new Payment(address, label, value.longValue(), false));
SendToAddress sendToAddress = (SendToAddress)rowCells.getFirst().getItem();
if(sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
return true;
}
}
return payments;
return false;
}
private class SendToManyDialogPane extends DialogPane {
@ -153,7 +192,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
loadButton.setGraphic(GlyphUtils.getUpArrowGlyph());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
@ -168,7 +207,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try {
List<Payment> csvPayments = new ArrayList<>();
List<SendToPayment> csvPayments = new ArrayList<>();
try(Reader reader = new FileReader(file, StandardCharsets.UTF_8)) {
CsvReader csvReader = new CsvReader(reader);
while(csvReader.readRecord()) {
@ -184,9 +223,22 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
} else {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
}
Address address = Address.fromString(csvReader.get(0));
String label = csvReader.get(2);
csvPayments.add(new Payment(address, label, amount, false));
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
if(optDnsPaymentHrn.isPresent()) {
Payment payment = new Payment(null, label, amount, false);
csvPayments.add(new SendToPayment(payment, new SendToAddress(optDnsPaymentHrn.get())));
} else {
try {
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(csvReader.get(0));
Payment payment = new SilentPayment(silentPaymentAddress, label, amount, false);
csvPayments.add(new SendToPayment(payment, SendToAddress.fromPayment(payment)));
} catch(Exception e) {
Address address = Address.fromString(csvReader.get(0));
Payment payment = new Payment(address, label, amount, false);
csvPayments.add(new SendToPayment(payment, SendToAddress.fromPayment(payment)));
}
}
} catch(NumberFormatException e) {
//ignore and continue - probably a header line
} catch(InvalidAddressException e) {
@ -199,7 +251,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return;
}
spreadsheetView.setGrid(getGrid(csvPayments));
spreadsheetView.setGrid(createGrid(csvPayments));
}
} catch(IOException e) {
AppServices.showErrorDialog("Cannot load CSV", e.getMessage());
@ -214,24 +266,18 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class AddressCellType extends SpreadsheetCellType<Address> {
public AddressCellType() {
this(new StringConverterWithFormat<>(new AddressStringConverter()) {
public static class SendToAddressCellType extends SpreadsheetCellType<SendToAddress> {
public SendToAddressCellType() {
this(new StringConverterWithFormat<>(new SendToAddressStringConverter()) {
@Override
public String toString(Address item) {
public String toString(SendToAddress item) {
return toStringFormat(item, ""); //$NON-NLS-1$
}
@Override
public Address fromString(String str) {
public SendToAddress fromString(String str) {
if(str == null || str.isEmpty()) { //$NON-NLS-1$
return null;
} else {
@ -240,7 +286,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
@Override
public String toStringFormat(Address item, String format) {
public String toStringFormat(SendToAddress item, String format) {
try {
if(item == null) {
return ""; //$NON-NLS-1$
@ -254,7 +300,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
});
}
public AddressCellType(StringConverter<Address> converter) {
public SendToAddressCellType(StringConverter<SendToAddress> converter) {
super(converter);
}
@ -264,7 +310,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
public SpreadsheetCell createCell(final int row, final int column, final int rowSpan, final int columnSpan,
final Address value) {
final SendToAddress value) {
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
cell.setItem(value);
return cell;
@ -277,7 +323,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
@Override
public boolean match(Object value, Object... options) {
if(value instanceof Address)
if(value instanceof SendToAddress)
return true;
else {
try {
@ -290,9 +336,9 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
@Override
public Address convertValue(Object value) {
if(value instanceof Address)
return (Address)value;
public SendToAddress convertValue(Object value) {
if(value instanceof SendToAddress)
return (SendToAddress)value;
else {
try {
return converter.fromString(value == null ? null : value.toString());
@ -303,13 +349,155 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
@Override
public String toString(Address item) {
public String toString(SendToAddress item) {
return converter.toString(item);
}
@Override
public String toString(Address item, String format) {
return ((StringConverterWithFormat<Address>)converter).toStringFormat(item, format);
public String toString(SendToAddress item, String format) {
return ((StringConverterWithFormat<SendToAddress>)converter).toStringFormat(item, format);
}
};
public static class SendToAddress {
private final String hrn;
private final Address address;
private final SilentPaymentAddress silentPaymentAddress;
public SendToAddress(String hrn) {
this.hrn = hrn;
this.address = null;
this.silentPaymentAddress = null;
}
public SendToAddress(Address address) {
this.hrn = null;
this.address = address;
this.silentPaymentAddress = null;
}
public SendToAddress(SilentPaymentAddress silentPaymentAddress) {
this.hrn = null;
this.address = null;
this.silentPaymentAddress = silentPaymentAddress;
}
public String toString() {
return hrn == null ? silentPaymentAddress == null ? (address == null ? null : address.toString()) : silentPaymentAddress.toString() : hrn;
}
public static SendToAddress fromPayment(Payment payment) {
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
if(dnsPayment != null) {
return new SendToAddress(dnsPayment.hrn());
}
return payment instanceof SilentPayment ? new SendToAddress(((SilentPayment)payment).getSilentPaymentAddress()) : new SendToAddress(payment.getAddress());
}
public Payment toPayment(String label, long value, boolean sendMax) throws DnsPaymentValidationException, IOException, ExecutionException, InterruptedException, BitcoinURIParseException {
if(hrn != null) {
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(hrn);
if(dnsPayment == null) {
DnsPaymentResolver resolver = new DnsPaymentResolver(hrn);
Optional<DnsPayment> optDnsPayment = resolver.resolve();
if(optDnsPayment.isPresent()) {
dnsPayment = optDnsPayment.get();
if(dnsPayment.hasAddress()) {
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment);
} else if(dnsPayment.hasSilentPaymentAddress()) {
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), dnsPayment);
}
return getPayment(optDnsPayment.get(), label, value, sendMax);
} else {
throw new IllegalArgumentException("Payment to " + hrn + " could not be resolved.");
}
} else {
return getPayment(dnsPayment, label, value, sendMax);
}
}
if(silentPaymentAddress != null) {
return new SilentPayment(silentPaymentAddress, label, value, sendMax);
} else {
return new Payment(address, label, value, sendMax);
}
}
private static Payment getPayment(DnsPayment dnsPayment, String label, long value, boolean sendMax) {
if(dnsPayment.hasAddress()) {
return new Payment(dnsPayment.bitcoinURI().getAddress(), label, value, sendMax);
} else if(dnsPayment.hasSilentPaymentAddress()) {
return new SilentPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), label, value, sendMax);
} else {
throw new IllegalArgumentException("Payment to " + dnsPayment + " has no associated address.");
}
}
}
private static class SendToAddressStringConverter extends StringConverter<SendToAddress> {
private final AddressStringConverter addressStringConverter = new AddressStringConverter();
@Override
public SendToAddress fromString(String value) {
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(value);
if(optDnsPaymentHrn.isPresent()) {
return new SendToAddress(optDnsPaymentHrn.get());
}
try {
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(value);
return new SendToAddress(silentPaymentAddress);
} catch(Exception e) {
Address address = addressStringConverter.fromString(value);
return address == null ? null : new SendToAddress(address);
}
}
@Override
public String toString(SendToAddress value) {
return value.toString();
}
}
private class CreatePaymentsService extends Service<List<Payment>> {
@Override
protected Task<List<Payment>> createTask() {
return new Task<>() {
@Override
protected List<Payment> call() throws Exception {
return getPayments();
}
};
}
private List<Payment> getPayments() throws DnsPaymentValidationException, IOException, ExecutionException, InterruptedException, BitcoinURIParseException {
List<Payment> payments = new ArrayList<>();
Grid grid = spreadsheetView.getGrid();
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
SendToAddress sendToAddress = (SendToAddress)rowCells.get(0).getItem();
Double value = (Double)rowCells.get(1).getItem();
String label = (String)rowCells.get(2).getItem();
if(firstLabel == null) {
firstLabel = label;
}
if(label == null || label.isEmpty()) {
label = firstLabel;
}
if(sendToAddress != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(sendToAddress.toPayment(label, value.longValue(), false));
}
}
return payments;
}
}
private record SendToPayment(Payment payment, SendToAddress sendToAddress) {}
}

View file

@ -4,8 +4,12 @@ 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.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.*;
@ -22,6 +26,7 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.EventHandler;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
@ -103,6 +108,7 @@ public class TransactionDiagram extends GridPane {
expandedDiagram.setId("transactionDiagram");
expandedDiagram.setExpanded(true);
expandedDiagram.setFinal(isFinal());
expandedDiagram.setMaxWidth(AppServices.getActiveWindow().getWidth() - 200);
updateDerivedDiagram(expandedDiagram);
HBox buttonBox = new HBox();
@ -120,7 +126,7 @@ public class TransactionDiagram extends GridPane {
AppServices.setStageIcon(stage);
stage.setScene(scene);
stage.setOnShowing(e -> {
AppServices.moveToActiveWindowScreen(stage, 600, 460);
AppServices.moveToActiveWindowScreen(stage, expandedDiagram.getMaxWidth(), 460);
});
stage.setOnHidden(e -> {
expandedDiagram = null;
@ -137,6 +143,39 @@ public class TransactionDiagram extends GridPane {
}
};
public TransactionDiagram() {
ColumnConstraints col1 = new ColumnConstraints();
col1.setPrefWidth(22);
col1.setHgrow(Priority.NEVER);
ColumnConstraints col2 = new ColumnConstraints();
col2.setHgrow(Priority.ALWAYS);
col2.setPercentWidth(25);
col2.setFillWidth(true);
ColumnConstraints col3 = new ColumnConstraints();
col3.setPrefWidth(140);
col3.setHgrow(Priority.NEVER);
ColumnConstraints col4 = new ColumnConstraints();
Label label = new Label();
col4.setMinWidth(TextUtils.computeTextWidth(label.getFont(), "Transaction", 0) + 20);
col4.setHgrow(Priority.NEVER);
col4.setHalignment(HPos.CENTER);
ColumnConstraints col5 = new ColumnConstraints();
col5.setPrefWidth(140);
col5.setHgrow(Priority.NEVER);
ColumnConstraints col6 = new ColumnConstraints();
col6.setHgrow(Priority.ALWAYS);
col6.setPercentWidth(25);
col6.setFillWidth(true);
getColumnConstraints().addAll(col1, col2, col3, col4, col5, col6);
setPadding(new Insets(0, 0, 0, 40));
}
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
setMaxHeight(getDiagramHeight());
@ -165,7 +204,7 @@ public class TransactionDiagram extends GridPane {
VBox messagePane = new VBox();
messagePane.setPrefHeight(getDiagramHeight());
messagePane.setPadding(new Insets(0, 10, 0, 280));
messagePane.setPadding(new Insets(0, 10, 0, 10));
messagePane.setAlignment(Pos.CENTER);
messagePane.getChildren().add(createSpacer());
@ -225,7 +264,6 @@ public class TransactionDiagram extends GridPane {
GridPane.setConstraints(outputsPane, 5, 0);
getChildren().clear();
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
List<Payment> userPayments = getUserPayments();
if(!isFinal() && userPayments.size() > 1) {
@ -234,6 +272,8 @@ public class TransactionDiagram extends GridPane {
getChildren().add(totalsPane);
}
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
if(contextMenu == null) {
contextMenu = new ContextMenu();
MenuItem menuItem = new MenuItem("Save as Image...");
@ -407,8 +447,6 @@ public class TransactionDiagram extends GridPane {
private Pane getInputsLabels(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
VBox inputsBox = new VBox();
inputsBox.setMaxWidth(isExpanded() ? 300 : 150);
inputsBox.setPrefWidth(isExpanded() ? 230 : 150);
inputsBox.setPadding(new Insets(0, 10, 0, 10));
inputsBox.minHeightProperty().bind(minHeightProperty());
inputsBox.setAlignment(Pos.BASELINE_RIGHT);
@ -640,7 +678,8 @@ public class TransactionDiagram extends GridPane {
double width = 140.0;
long sum = walletTx.getTotal();
List<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
List<Long> values = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput))
.map(output -> output.getTransactionOutput().getValue()).collect(Collectors.toList());
values.add(walletTx.getFee());
int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
for(int i = 1; i <= numOutputs; i++) {
@ -676,8 +715,6 @@ public class TransactionDiagram extends GridPane {
private Pane getOutputsLabels(List<Payment> displayedPayments) {
VBox outputsBox = new VBox();
outputsBox.setMaxWidth(isExpanded() ? 350 : 150);
outputsBox.setPrefWidth(isExpanded() ? 230 : 150);
outputsBox.setPadding(new Insets(0, 20, 0, 10));
outputsBox.setAlignment(Pos.BASELINE_LEFT);
outputsBox.getChildren().add(createSpacer());
@ -686,15 +723,16 @@ public class TransactionDiagram extends GridPane {
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", "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);
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
WalletNode toNode = payment instanceof WalletNodePayment walletNodePayment ? walletNodePayment.getWalletNode() : null;
Wallet toBip47Wallet = getBip47SendWallet(payment);
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (dnsPayment == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : dnsPayment.toString()) : toWallet.getFullDisplayName()) + "\n" + payment.getDisplayAddress())
+ (walletTx.isDuplicateAddress(payment) ? " (Duplicate)" : ""));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
@ -719,9 +757,13 @@ public class TransactionDiagram extends GridPane {
paymentBox.getChildren().addAll(region, amountLabel);
}
if(payment instanceof SilentPayment silentPayment) {
outputNodes.add(new OutputNode(paymentBox, silentPayment.isAddressComputed() ? silentPayment.getAddress() : null, payment.getAmount(), null, silentPayment.getSilentPaymentAddress()));
} else {
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));
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode, null));
}
}
Set<Integer> seenIndexes = new HashSet<>();
@ -785,7 +827,7 @@ public class TransactionDiagram extends GridPane {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode);
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode, outputNode.silentPaymentAddress);
if(!outputNode.outputLabel.getChildren().isEmpty() && outputNode.outputLabel.getChildren().get(0) instanceof Label outputLabelControl) {
outputLabelControl.setContextMenu(contextMenu);
}
@ -960,8 +1002,11 @@ public class TransactionDiagram extends GridPane {
}
private int getOutputIndex(Address address, long amount, Collection<Integer> seenIndexes) {
List<TransactionOutput> addressOutputs = walletTx.getTransaction().getOutputs().stream().filter(txOutput -> txOutput.getScript().getToAddress() != null).collect(Collectors.toList());
TransactionOutput output = addressOutputs.stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex())).findFirst().orElseThrow();
List<TransactionOutput> addressOutputs = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput))
.map(WalletTransaction.Output::getTransactionOutput).collect(Collectors.toList());
TransactionOutput output = addressOutputs.stream()
.filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex()))
.findFirst().orElseThrow();
return addressOutputs.indexOf(output);
}
@ -1111,7 +1156,7 @@ public class TransactionDiagram extends GridPane {
}
public String toString() {
return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n"));
return additionalPayments.stream().map(Payment::toString).collect(Collectors.joining("\n"));
}
}
@ -1120,25 +1165,27 @@ public class TransactionDiagram extends GridPane {
public Address address;
public long amount;
public PaymentCode paymentCode;
public SilentPaymentAddress silentPaymentAddress;
public OutputNode(Pane outputLabel, Address address, long amount) {
this(outputLabel, address, amount, null);
this(outputLabel, address, amount, null, null);
}
public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode) {
public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode, SilentPaymentAddress silentPaymentAddress) {
this.outputLabel = outputLabel;
this.address = address;
this.amount = amount;
this.paymentCode = paymentCode;
this.silentPaymentAddress = silentPaymentAddress;
}
}
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
this(address, value, null);
this(address, value, null, null);
}
public LabelContextMenu(Address address, long value, PaymentCode paymentCode) {
public LabelContextMenu(Address address, long value, PaymentCode paymentCode, SilentPaymentAddress silentPaymentAddress) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(event -> {
@ -1186,6 +1233,17 @@ public class TransactionDiagram extends GridPane {
});
getItems().add(copyPaymentCode);
}
if(silentPaymentAddress != null) {
MenuItem copySilentPaymentAddress = new MenuItem("Copy Silent Payment Address");
copySilentPaymentAddress.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(silentPaymentAddress.toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copySilentPaymentAddress);
}
}
}
}

View file

@ -90,20 +90,20 @@ public class TransactionDiagramLabel extends HBox {
outputLabels.add(mixOutputLabel);
}
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1 && walletTx.getWallet() != null
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && !walletTx.getWalletNodePayments().isEmpty()) {
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
if(remixOutputLabel != null) {
outputLabels.add(remixOutputLabel);
}
} else {
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<Payment> payments = walletTx.getExternalPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).collect(Collectors.toList());
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
}
outputLabels.addAll(paymentLabels);
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
List<Payment> consolidations = walletTx.getWalletNodePayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT).collect(Collectors.toList());
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
@ -203,10 +203,10 @@ public class TransactionDiagramLabel extends HBox {
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
WalletNode toNode = payment instanceof WalletNodePayment walletNodePayment ? walletNodePayment.getWalletNode() : null;
Glyph glyph = GlyphUtils.getOutputGlyph(transactionDiagram.getWalletTransaction(), payment);
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment;
return getOutputLabel(glyph, text);
}
@ -240,7 +240,7 @@ public class TransactionDiagramLabel extends HBox {
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setFont(Font.font("Roboto Mono Italic", 13));
label.setFont(Font.font("Fragment Mono Italic", 13));
label.setText(text);
HBox output = new HBox(5);

View file

@ -6,8 +6,9 @@ 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);
PIX_FMT_NV12("NV12", true),
PIX_FMT_YU12("YU12", true),
PIX_FMT_MJPG("MJPG", true);
private final String name;
private final boolean supported;
@ -25,6 +26,14 @@ public enum WebcamPixelFormat {
return supported;
}
public int getFourCC() {
char a = name.charAt(0);
char b = name.charAt(1);
char c = name.charAt(2);
char d = name.charAt(3);
return ((int) a) | ((int) b << 8) | ((int) c << 16) | ((int) d << 24);
}
public String toString() {
return name;
}

View file

@ -7,6 +7,7 @@ import com.google.zxing.qrcode.QRCodeReader;
import com.sparrowwallet.bokmakierie.Bokmakierie;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ZBar;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
@ -15,7 +16,6 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import net.sourceforge.zbar.ZBar;
import org.openpnp.capture.*;
import org.openpnp.capture.library.OpenpnpCaptureLibrary;
import org.slf4j.Logger;
@ -27,6 +27,9 @@ import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.*;
import java.util.List;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -34,13 +37,18 @@ import java.util.stream.Stream;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private final Semaphore taskSemaphore = new Semaphore(1);
private final AtomicBoolean cancelRequested = new AtomicBoolean(false);
private final AtomicBoolean captureClosed = new AtomicBoolean(false);
private List<CaptureDevice> devices;
private List<CaptureDevice> availableDevices;
private Set<WebcamResolution> resolutions;
private WebcamResolution resolution;
private CaptureDevice device;
private final BooleanProperty opening = new SimpleBooleanProperty(false);
private final BooleanProperty closed = new SimpleBooleanProperty(false);
private final BooleanProperty opened = new SimpleBooleanProperty(false);
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
@ -105,26 +113,44 @@ public class WebcamService extends ScheduledService<Image> {
return new Task<>() {
@Override
protected Image call() throws Exception {
if(cancelRequested.get() || isCancelled() || captureClosed.get()) {
return null;
}
if(!taskSemaphore.tryAcquire()) {
log.warn("Skipped execution of webcam capture task, another task is running");
return null;
}
try {
if(stream == null) {
if(devices == null) {
devices = capture.getDevices();
availableDevices = new ArrayList<>(devices);
if(devices.isEmpty()) {
throw new UnsupportedOperationException("No cameras available");
}
}
CaptureDevice selectedDevice = devices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(devices.getFirst());
while(stream == null && !availableDevices.isEmpty()) {
CaptureDevice selectedDevice = availableDevices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(availableDevices.getFirst());
if(device != null) {
for(CaptureDevice webcam : devices) {
if(webcam.getName().equals(device.getName())) {
for(CaptureDevice webcam : availableDevices) {
if(webcam.equals(device)) {
selectedDevice = webcam;
break;
}
}
} else if(Config.get().getWebcamDevice() != null) {
for(CaptureDevice webcam : devices) {
for(CaptureDevice webcam : availableDevices) {
if(webcam.getUniqueId().equals(Config.get().getWebcamDeviceId())) {
selectedDevice = webcam;
break;
}
if(webcam.getName().equals(Config.get().getWebcamDevice())) {
selectedDevice = webcam;
break;
}
}
}
@ -163,6 +189,11 @@ public class WebcamService extends ScheduledService<Image> {
}
}
//On Linux, formats not defined in WebcamPixelFormat are unsupported
if(OsType.getCurrent() == OsType.UNIX && WebcamPixelFormat.fromFourCC(format.getFormatInfo().fourcc) == null) {
log.warn("Unsupported camera pixel format " + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc));
}
if(log.isDebugEnabled()) {
log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")");
}
@ -170,15 +201,23 @@ public class WebcamService extends ScheduledService<Image> {
opening.set(true);
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");
}
if(stream == null) {
availableDevices.remove(device);
}
}
if(stream == null) {
throw new UnsupportedOperationException("No usable cameras available, tried " + devices);
}
opened.set(true);
BufferedImage originalImage = stream.capture();
CroppedDimension cropped = getCroppedDimension(originalImage);
BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length);
@ -195,6 +234,7 @@ public class WebcamService extends ScheduledService<Image> {
return image;
} finally {
opening.set(false);
taskSemaphore.release();
}
}
};
@ -204,22 +244,39 @@ public class WebcamService extends ScheduledService<Image> {
public void reset() {
stream = null;
zoomLimits = null;
cancelRequested.set(false);
super.reset();
}
@Override
public boolean cancel() {
cancelRequested.set(true);
boolean cancelled = super.cancel();
try {
if(taskSemaphore.tryAcquire(1, TimeUnit.SECONDS)) {
taskSemaphore.release();
} else {
log.error("Timed out waiting for task semaphore to be available to cancel, cancelling anyway");
}
} catch(InterruptedException e) {
log.error("Interrupted while waiting for task semaphore to be available to cancel, cancelling anyway");
}
if(stream != null) {
stream.close();
closed.set(true);
opened.set(false);
}
return super.cancel();
return cancelled;
}
public void close() {
public synchronized void close() {
if(!captureClosed.get()) {
captureClosed.set(true);
capture.close();
}
}
public PropertyLimits getZoomLimits() {
return zoomLimits;
@ -262,9 +319,6 @@ public class WebcamService extends ScheduledService<Image> {
}
private Result readQR(BufferedImage bufferedImage) {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
com.sparrowwallet.bokmakierie.Result result = bokmakierie.scan(bufferedImage);
if(result != null) {
@ -282,6 +336,8 @@ public class WebcamService extends ScheduledService<Image> {
}
try {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
return qrReader.decode(bitmap, Map.of(DecodeHintType.TRY_HARDER, Boolean.TRUE));
} catch(ReaderException e) {
// fall thru, it means there is no QR code in image
@ -336,6 +392,10 @@ public class WebcamService extends ScheduledService<Image> {
return devices;
}
public List<CaptureDevice> getAvailableDevices() {
return availableDevices;
}
public Set<WebcamResolution> getResolutions() {
return resolutions;
}
@ -376,8 +436,12 @@ public class WebcamService extends ScheduledService<Image> {
return opening;
}
public BooleanProperty closedProperty() {
return closed;
public BooleanProperty openedProperty() {
return opened;
}
public boolean getCancelRequested() {
return cancelRequested.get();
}
public static <T extends Enum<T>> T getNearestEnum(T target) {
@ -385,10 +449,27 @@ public class WebcamService extends ScheduledService<Image> {
}
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);
if(values == null || values.length == 0) {
return null;
}
int targetOrdinal = target.ordinal();
if(values.length == 1) {
return values[0];
}
for(int i = 0; i < values.length; i++) {
if(targetOrdinal < values[i].ordinal()) {
if(i == 0) {
return values[0];
}
int diffToPrev = Math.abs(targetOrdinal - values[i - 1].ordinal());
int diffToNext = Math.abs(targetOrdinal - values[i].ordinal());
return diffToPrev <= diffToNext ? values[i - 1] : values[i];
}
}
return values[values.length - 1];
}
private static class CroppedDimension {

View file

@ -61,7 +61,7 @@ public class WebcamView {
});
service.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue != null && !service.getCancelRequested()) {
imageProperty.set(newValue);
}
});

View file

@ -6,12 +6,18 @@ import java.util.Map;
public class BlockSummaryEvent {
private final Map<Integer, BlockSummary> blockSummaryMap;
private final Double nextBlockMedianFeeRate;
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap) {
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap, Double nextBlockMedianFeeRate) {
this.blockSummaryMap = blockSummaryMap;
this.nextBlockMedianFeeRate = nextBlockMedianFeeRate;
}
public Map<Integer, BlockSummary> getBlockSummaryMap() {
return blockSummaryMap;
}
public Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate;
}
}

View file

@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.List;
@ -13,6 +14,7 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
private final int blockHeight;
private final BlockHeader blockHeader;
private final Double minimumRelayFeeRate;
private final Double previousMinimumRelayFeeRate;
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double minimumRelayFeeRate) {
super(targetBlockFeeRates, mempoolRateSizes);
@ -21,6 +23,7 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
this.blockHeight = blockHeight;
this.blockHeader = blockHeader;
this.minimumRelayFeeRate = minimumRelayFeeRate;
this.previousMinimumRelayFeeRate = AppServices.getMinimumRelayFeeRate();
}
public List<String> getServerVersion() {
@ -42,4 +45,8 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
public Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate;
}
public Double getPreviousMinimumRelayFeeRate() {
return previousMinimumRelayFeeRate;
}
}

View file

@ -7,13 +7,23 @@ import java.util.Set;
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
private final Double nextBlockMedianFeeRate;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
this(targetBlockFeeRates, mempoolRateSizes, null);
}
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double nextBlockMedianFeeRate) {
super(mempoolRateSizes);
this.targetBlockFeeRates = targetBlockFeeRates;
this.nextBlockMedianFeeRate = nextBlockMedianFeeRate;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
public Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate;
}
}

View file

@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Payment;
import java.util.List;
public class RequestSendToManyEvent {
private final List<Payment> payments;
public RequestSendToManyEvent(List<Payment> payments) {
this.payments = payments;
}
public List<Payment> getPayments() {
return payments;
}
}

View file

@ -17,12 +17,13 @@ public class SpendUtxoEvent {
private final boolean requireAllUtxos;
private final BlockTransaction replacedTransaction;
private final PaymentCode paymentCode;
private final boolean allowPaymentChanges;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, null, false, null);
this(wallet, utxos, null, null, null, false, null, true);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction, boolean allowPaymentChanges) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
@ -31,6 +32,7 @@ public class SpendUtxoEvent {
this.requireAllUtxos = requireAllUtxos;
this.replacedTransaction = replacedTransaction;
this.paymentCode = null;
this.allowPaymentChanges = allowPaymentChanges;
}
public SpendUtxoEvent(Wallet wallet, List<Payment> payments, List<byte[]> opReturns, PaymentCode paymentCode) {
@ -42,6 +44,7 @@ public class SpendUtxoEvent {
this.requireAllUtxos = false;
this.replacedTransaction = null;
this.paymentCode = paymentCode;
this.allowPaymentChanges = false;
}
public Wallet getWallet() {
@ -75,4 +78,8 @@ public class SpendUtxoEvent {
public PaymentCode getPaymentCode() {
return paymentCode;
}
public boolean allowPaymentChanges() {
return allowPaymentChanges;
}
}

View file

@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionOutputsChangedEvent extends TransactionChangedEvent {
public TransactionOutputsChangedEvent(Transaction transaction) {
super(transaction);
}
}

View file

@ -14,9 +14,16 @@ import java.util.List;
*/
public class WalletNodeHistoryChangedEvent {
private final String scriptHash;
private final String status;
public WalletNodeHistoryChangedEvent(String scriptHash) {
this.scriptHash = scriptHash;
this.status = null;
}
public WalletNodeHistoryChangedEvent(String scriptHash, String status) {
this.scriptHash = scriptHash;
this.status = status;
}
public WalletNode getWalletNode(Wallet wallet) {
@ -70,4 +77,8 @@ public class WalletNodeHistoryChangedEvent {
public String getScriptHash() {
return scriptHash;
}
public String getStatus() {
return status;
}
}

View file

@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.glyphfont;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.WalletNodePayment;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
@ -15,7 +16,7 @@ public class GlyphUtils {
return getFakeMixGlyph();
} else if(payment.getType().equals(Payment.Type.ANCHOR)) {
return getAnchorGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
} else if(payment instanceof WalletNodePayment) {
return getConsolidationGlyph();
} else if(walletTx.isPremixSend(payment)) {
return getPremixGlyph();
@ -213,6 +214,13 @@ public class GlyphUtils {
return busyGlyph;
}
public static Glyph getUpArrowGlyph() {
Glyph upGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_UP);
upGlyph.getStyleClass().add("arrow-up");
upGlyph.setFontSize(12);
return upGlyph;
}
public static Glyph getDownArrowGlyph() {
Glyph downGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
downGlyph.getStyleClass().add("arrow-down");

View file

@ -192,7 +192,7 @@ public class Bip129 implements KeystoreFileExport, KeystoreFileImport, WalletExp
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
String record = "BSMS 1.0\n" +
OutputDescriptor.getOutputDescriptor(wallet) +
OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null) +
"\n/0/*,/1/*\n" +
wallet.getNode(KeyPurpose.RECEIVE).getChildren().iterator().next().getAddress();
outputStream.write(record.getBytes(StandardCharsets.UTF_8));

View file

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
@ -52,6 +53,9 @@ public class Config {
private boolean showDeprecatedImportExport = false;
private boolean signBsmsExports = false;
private boolean preventSleep = false;
private Boolean connectToBroadcast;
private Boolean connectToResolve;
private Boolean suggestSendToMany;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
@ -61,6 +65,7 @@ public class Config {
private boolean mirrorCapture = true;
private boolean useZbar = true;
private String webcamDevice;
private String webcamDeviceId;
private ServerType serverType;
private Server publicElectrumServer;
private Server coreServer;
@ -69,6 +74,7 @@ public class Config {
private File coreDataDir;
private String coreAuth;
private boolean useLegacyCoreWallet;
private boolean legacyServer;
private Server electrumServer;
private List<Server> recentElectrumServers;
private File electrumServerCert;
@ -79,6 +85,7 @@ public class Config {
private int maxPageSize = DEFAULT_PAGE_SIZE;
private boolean usePayNym;
private boolean mempoolFullRbf;
private double minRelayFeeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
private Double appWidth;
private Double appHeight;
@ -347,6 +354,34 @@ public class Config {
public void setPreventSleep(boolean preventSleep) {
this.preventSleep = preventSleep;
flush();
}
public Boolean getConnectToBroadcast() {
return connectToBroadcast;
}
public void setConnectToBroadcast(Boolean connectToBroadcast) {
this.connectToBroadcast = connectToBroadcast;
flush();
}
public Boolean getConnectToResolve() {
return connectToResolve;
}
public void setConnectToResolve(Boolean connectToResolve) {
this.connectToResolve = connectToResolve;
flush();
}
public Boolean getSuggestSendToMany() {
return suggestSendToMany;
}
public void setSuggestSendToMany(Boolean suggestSendToMany) {
this.suggestSendToMany = suggestSendToMany;
flush();
}
public List<File> getRecentWalletFiles() {
@ -415,6 +450,15 @@ public class Config {
flush();
}
public String getWebcamDeviceId() {
return webcamDeviceId;
}
public void setWebcamDeviceId(String webcamDeviceId) {
this.webcamDeviceId = webcamDeviceId;
flush();
}
public ServerType getServerType() {
return serverType;
}
@ -549,6 +593,15 @@ public class Config {
flush();
}
public boolean isLegacyServer() {
return legacyServer;
}
public void setLegacyServer(boolean legacyServer) {
this.legacyServer = legacyServer;
flush();
}
public Server getElectrumServer() {
return electrumServer;
}
@ -667,6 +720,14 @@ public class Config {
flush();
}
public double getMinRelayFeeRate() {
return minRelayFeeRate;
}
public void setMinRelayFeeRate(double minRelayFeeRate) {
this.minRelayFeeRate = minRelayFeeRate;
}
public Double getAppWidth() {
return appWidth;
}

View file

@ -126,7 +126,7 @@ public class Descriptor implements WalletImport, WalletExport {
} else if(line.startsWith("#")) {
continue;
} else {
paragraph.append(line);
paragraph.append(line.replaceFirst("^.+:", "").trim());
}
}

View file

@ -37,7 +37,8 @@ public class ElectrumPersonalServer implements WalletExport {
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.write("# Electrum Personal Server configuration file fragments\n");
writer.write("# Copy the lines below into the relevant sections in your EPS config.ini file\n\n");
writer.write("# First close Sparrow and edit your config file in Sparrow home to set \"legacyServer\": true\n");
writer.write("# Then copy the lines below into the relevant sections in your EPS config.ini file\n\n");
writer.write("# Copy into [master-public-keys] section\n");
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
writeWalletXpub(masterWallet, writer);

View file

@ -684,7 +684,7 @@ public class Storage {
public static Executor getSingleThreadedExecutor() {
if(singleThreadedExecutor == null) {
BasicThreadFactory factory = new BasicThreadFactory.Builder().namingPattern("LoadWalletService-single").daemon(true).priority(Thread.MIN_PRIORITY).build();
BasicThreadFactory factory = BasicThreadFactory.builder().namingPattern("LoadWalletService-single").daemon(true).priority(Thread.MIN_PRIORITY).build();
singleThreadedExecutor = Executors.newSingleThreadScheduledExecutor(factory);
}

View file

@ -1,29 +1,21 @@
package net.sourceforge.zbar;
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.sparrow.net.NativeUtils;
import io.github.doblon8.jzbar.Config;
import io.github.doblon8.jzbar.Image;
import io.github.doblon8.jzbar.ImageScanner;
import io.github.doblon8.jzbar.SymbolType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.util.Iterator;
public class ZBar {
private static final Logger log = LoggerFactory.getLogger(ZBar.class);
private final static boolean enabled;
static { // static initializer
if(com.sparrowwallet.sparrow.io.Config.get().isUseZbar()) {
enabled = loadLibrary();
} else {
enabled = false;
}
}
public static boolean isEnabled() {
return enabled;
return com.sparrowwallet.sparrow.io.Config.get().isUseZbar();
}
public static Scan scan(BufferedImage bufferedImage) {
@ -41,19 +33,12 @@ public class ZBar {
image.setData(data);
try(ImageScanner scanner = new ImageScanner()) {
scanner.setConfig(Symbol.NONE, Config.ENABLE, 0);
scanner.setConfig(Symbol.QRCODE, Config.ENABLE, 1);
scanner.setConfig(SymbolType.NONE, Config.ENABLE, 0);
scanner.setConfig(SymbolType.QRCODE, Config.ENABLE, 1);
int result = scanner.scanImage(image);
if(result != 0) {
try(SymbolSet results = scanner.getResults()) {
Scan scan = null;
for(Iterator<Symbol> iter = results.iterator(); iter.hasNext(); ) {
try(Symbol symbol = iter.next()) {
scan = new Scan(getRawBytes(symbol.getData()), symbol.getData());
}
}
return scan;
}
String symbolData = image.getFirstSymbol().getData();
return new Scan(getRawBytes(symbolData), symbolData);
}
}
}
@ -97,31 +82,6 @@ public class ZBar {
return outputData;
}
private static boolean loadLibrary() {
try {
String osName = System.getProperty("os.name");
String osArch = System.getProperty("os.arch");
if(osName.startsWith("Mac") && osArch.equals("aarch64")) {
NativeUtils.loadLibraryFromJar("/native/osx/aarch64/libzbar.dylib");
} else if(osName.startsWith("Mac")) {
NativeUtils.loadLibraryFromJar("/native/osx/x64/libzbar.dylib");
} else if(osName.startsWith("Windows")) {
NativeUtils.loadLibraryFromJar("/native/windows/x64/iconv-2.dll");
NativeUtils.loadLibraryFromJar("/native/windows/x64/zbar.dll");
} else if(osArch.equals("aarch64")) {
NativeUtils.loadLibraryFromJar("/native/linux/aarch64/libzbar.so");
} else {
NativeUtils.loadLibraryFromJar("/native/linux/x64/libzbar.so");
}
return true;
} catch(Exception e) {
log.warn("Could not load ZBar native libraries, disabling. " + e.getMessage());
}
return false;
}
private static byte[] getRawBytes(String str) {
char[] chars = str.toCharArray();
byte[] bytes = new byte[chars.length];

View file

@ -171,7 +171,7 @@ public class DbPersistence implements Persistence {
private synchronized void createUpdateExecutor(Wallet masterWallet) {
if(updateExecutor == null) {
BasicThreadFactory factory = new BasicThreadFactory.Builder().namingPattern(masterWallet.getFullName() + "-dbupdater").daemon(true).priority(Thread.NORM_PRIORITY).build();
BasicThreadFactory factory = BasicThreadFactory.builder().namingPattern(masterWallet.getFullName() + "-dbupdater").daemon(true).priority(Thread.NORM_PRIORITY).build();
updateExecutor = Executors.newSingleThreadExecutor(factory);
}
}

View file

@ -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;
}
}

View file

@ -76,6 +76,10 @@ public class ElectrumServer {
private static final Set<String> sameHeightTxioScriptHashes = ConcurrentHashMap.newKeySet();
private final static Map<String, Integer> subscribedRecent = new ConcurrentHashMap<>();
private final static Map<String, String> broadcastRecent = new ConcurrentHashMap<>();
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
private static Cormorant cormorant;
@ -261,7 +265,7 @@ public class ElectrumServer {
return 0;
});
return txos.stream().map(txo -> new ScriptHashTx(txo.getHeight(), txo.getHashAsString(), txo.getFee())).toList();
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) {
@ -439,7 +443,7 @@ public class ElectrumServer {
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()));
scriptHashTxes.add(new ScriptHashTx(0, txid.toString(), blkTx.getFee() == null ? 0 : blkTx.getFee()));
String status = getScriptHashStatus(scriptHashTxes);
if(Objects.equals(status, statuses.getLast())) {
@ -936,6 +940,20 @@ public class ElectrumServer {
return targetBlocksFeeRatesSats;
}
public Double getNextBlockMedianFeeRate() {
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get())) {
try {
return feeRatesSource.getNextBlockMedianFeeRate();
} catch(Exception e) {
return null;
}
}
return null;
}
public Map<Integer, Double> getDefaultFeeEstimates(List<Integer> targetBlocks) throws ServerException {
try {
Map<Integer, Double> targetBlocksFeeRatesBtcKb = electrumServerRpc.getFeeEstimates(getTransport(), targetBlocks);
@ -1048,6 +1066,11 @@ public class ElectrumServer {
List<BlockTransactionHash> recentTransactions = feeRatesSource.getRecentMempoolTransactions();
Map<BlockTransactionHash, Transaction> setReferences = new HashMap<>();
setReferences.put(recentTransactions.getFirst(), null);
if(recentTransactions.size() > 1) {
Random random = new Random();
int halfSize = recentTransactions.size() / 2;
setReferences.put(recentTransactions.get(halfSize == 1 ? 1 : random.nextInt(halfSize) + 1), null);
}
Map<Sha256Hash, BlockTransaction> transactions = getTransactions(null, setReferences, Collections.emptyMap());
return transactions.values().stream().filter(blxTx -> blxTx.getTransaction() != null).toList();
} catch(Exception e) {
@ -1232,11 +1255,11 @@ public class ElectrumServer {
if(!serverVersion.isEmpty()) {
String server = serverVersion.getFirst().toLowerCase(Locale.ROOT);
if(server.contains("electrumx")) {
return new ServerCapability(true);
return new ServerCapability(true, true);
}
if(server.startsWith("cormorant")) {
return new ServerCapability(true, false, true);
return new ServerCapability(true, false, true, false);
}
if(server.startsWith("electrs/")) {
@ -1248,7 +1271,7 @@ public class ElectrumServer {
try {
Version version = new Version(electrsVersion);
if(version.compareTo(ELECTRS_MIN_BATCHING_VERSION) >= 0) {
return new ServerCapability(true);
return new ServerCapability(true, true);
}
} catch(Exception e) {
//ignore
@ -1264,7 +1287,7 @@ public class ElectrumServer {
try {
Version version = new Version(fulcrumVersion);
if(version.compareTo(FULCRUM_MIN_BATCHING_VERSION) >= 0) {
return new ServerCapability(true);
return new ServerCapability(true, true);
}
} catch(Exception e) {
//ignore
@ -1283,15 +1306,19 @@ public class ElectrumServer {
Version version = new Version(mempoolElectrsVersion);
if(version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) > 0 ||
(version.compareTo(MEMPOOL_ELECTRS_MIN_BATCHING_VERSION) == 0 && (!mempoolElectrsSuffix.contains("dev") || mempoolElectrsSuffix.contains("dev-249848d")))) {
return new ServerCapability(true, 25);
return new ServerCapability(true, 25, false);
}
} catch(Exception e) {
//ignore
}
}
if(server.startsWith("electrumpersonalserver")) {
return new ServerCapability(false, false);
}
}
return new ServerCapability(false);
return new ServerCapability(false, true);
}
public static class ServerVersionService extends Service<List<String>> {
@ -1456,8 +1483,9 @@ public class ElectrumServer {
if(elapsed > FEE_RATES_PERIOD) {
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
Double nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate();
feeRatesRetrievedAt = System.currentTimeMillis();
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes, nextBlockMedianFeeRate);
}
} else {
closeConnection();
@ -1583,6 +1611,31 @@ public class ElectrumServer {
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
EventManager.get().post(new MempoolRateSizesUpdatedEvent(mempoolRateSizes));
}
@Subscribe
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
String status = broadcastRecent.remove(event.getScriptHash());
if(status != null && status.equals(event.getStatus())) {
Map<String, String> subscribeScriptHashes = new HashMap<>();
Random random = new Random();
int subscriptions = random.nextInt(2) + 1;
for(int i = 0; i < subscriptions; i++) {
byte[] randomScriptHashBytes = new byte[32];
random.nextBytes(randomScriptHashBytes);
String randomScriptHash = Utils.bytesToHex(randomScriptHashBytes);
if(!subscribedScriptHashes.containsKey(randomScriptHash)) {
subscribeScriptHashes.put("m/" + subscribeScriptHashes.size(), randomScriptHash);
}
}
try {
electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes);
subscribeScriptHashes.values().forEach(scriptHash -> subscribedRecent.put(scriptHash, AppServices.getCurrentBlockHeight()));
} catch(ElectrumServerRpcException e) {
log.debug("Error subscribing to recent mempool transaction outputs", e);
}
}
}
}
public static class ReadRunnable implements Runnable {
@ -1935,7 +1988,8 @@ public class ElectrumServer {
protected FeeRatesUpdatedEvent call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false);
return new FeeRatesUpdatedEvent(blockTargetFeeRates, null);
Double nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate();
return new FeeRatesUpdatedEvent(blockTargetFeeRates, null, nextBlockMedianFeeRate);
}
};
}
@ -1982,10 +2036,14 @@ public class ElectrumServer {
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);
subscribeRecent(electrumServer, AppServices.getCurrentBlockHeight() == null ? endHeight : AppServices.getCurrentBlockHeight());
}
return new BlockSummaryEvent(blockSummaryMap);
Double nextBlockMedianFeeRate = null;
if(!isBlockstorm(totalBlocks)) {
nextBlockMedianFeeRate = electrumServer.getNextBlockMedianFeeRate();
}
return new BlockSummaryEvent(blockSummaryMap, nextBlockMedianFeeRate);
}
};
}
@ -1994,44 +2052,73 @@ public class ElectrumServer {
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);
private void subscribeRecent(ElectrumServer electrumServer, int currentHeight) {
Set<String> unsubscribeScriptHashes = subscribedRecent.entrySet().stream().filter(entry -> entry.getValue() == null || entry.getValue() <= currentHeight - 3)
.map(Map.Entry::getKey).collect(Collectors.toSet());
unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey);
if(!unsubscribeScriptHashes.isEmpty() && serverCapability.supportsUnsubscribe()) {
electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes);
subscribedRecent.removeAll(unsubscribeScriptHashes);
}
subscribedRecent.keySet().removeAll(unsubscribeScriptHashes);
broadcastRecent.keySet().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++) {
for(int i = 0; i < blkTx.getTransaction().getOutputs().size(); i++) {
TransactionOutput txOutput = blkTx.getTransaction().getOutputs().get(i);
String scriptHash = getScriptHash(txOutput);
if(!subscribedScriptHashes.containsKey(scriptHash)) {
subscribeScriptHashes.put("m/" + i, getScriptHash(txOutput));
subscribeScriptHashes.put("m/" + subscribeScriptHashes.size(), scriptHash);
}
if(Math.random() < 0.1d) {
break;
}
}
}
if(!subscribeScriptHashes.isEmpty()) {
Random random = new Random();
int additionalRandomScriptHashes = random.nextInt(8);
for(int i = 0; i < additionalRandomScriptHashes; i++) {
byte[] randomScriptHashBytes = new byte[32];
random.nextBytes(randomScriptHashBytes);
String randomScriptHash = Utils.bytesToHex(randomScriptHashBytes);
if(!subscribedScriptHashes.containsKey(randomScriptHash)) {
subscribeScriptHashes.put("m/" + subscribeScriptHashes.size(), randomScriptHash);
}
}
try {
electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes);
subscribedRecent.addAll(subscribeScriptHashes.values());
subscribeScriptHashes.values().forEach(scriptHash -> subscribedRecent.put(scriptHash, currentHeight));
} catch(ElectrumServerRpcException e) {
log.debug("Error subscribing to recent mempool transactions", e);
}
}
if(!recentTransactions.isEmpty()) {
broadcastRecent(electrumServer, recentTransactions);
}
}
private void broadcastRecent(ElectrumServer electrumServer, List<BlockTransaction> recentTransactions) {
ScheduledService<Void> broadcastService = new ScheduledService<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
for(BlockTransaction blkTx : recentTransactions) {
if(!recentTransactions.isEmpty()) {
Random random = new Random();
if(random.nextBoolean()) {
BlockTransaction blkTx = recentTransactions.get(random.nextInt(recentTransactions.size()));
String scriptHash = getScriptHash(blkTx.getTransaction().getOutputs().getFirst());
String status = getScriptHashStatus(List.of(new ScriptHashTx(0, blkTx.getHashAsString(), blkTx.getFee())));
broadcastRecent.put(scriptHash, status);
electrumServer.broadcastTransaction(blkTx.getTransaction());
}
}
return null;
}
};

View file

@ -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;
@ -121,7 +124,7 @@ public enum ExchangeSource {
return historicalRates;
}
},
COINGECKO("Coingecko", "No historical rates") {
COINGECKO("Coingecko", "Historical rates for the last 365 days") {
@Override
public List<Currency> getSupportedCurrencies() {
return getRates().rates.entrySet().stream().filter(rate -> "fiat".equals(rate.getValue().type) && isValidISO4217Code(rate.getKey().toUpperCase(Locale.ROOT)))
@ -164,6 +167,11 @@ public enum ExchangeSource {
long startDate = start.getTime() / 1000;
long endDate = end.getTime() / 1000;
Calendar cal = Calendar.getInstance();
cal.add(Calendar.YEAR, -1);
startDate = Math.max(cal.getTimeInMillis() / 1000, startDate);
endDate = Math.max(cal.getTimeInMillis() / 1000, endDate);
String url = "https://api.coingecko.com/api/v3/coins/bitcoin/market_chart/range?vs_currency=" + currency.getCurrencyCode() + "&from=" + startDate + "&to=" + endDate;
if(log.isInfoEnabled()) {
@ -297,6 +305,19 @@ public enum ExchangeSource {
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;

View file

@ -7,9 +7,12 @@ 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.net.URL;
import java.util.*;
public enum FeeRatesSource {
@ -31,6 +34,12 @@ public enum FeeRatesSource {
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
@Override
public Double getNextBlockMedianFeeRate() throws Exception {
String url = getApiUrl() + "v1/fees/mempool-blocks";
return requestNextBlockMedianFeeRate(this, url);
}
@Override
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
String url = getApiUrl() + "v1/block/" + Utils.bytesToHex(blockId.getReversedBytes());
@ -127,6 +136,10 @@ public enum FeeRatesSource {
public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates);
public Double getNextBlockMedianFeeRate() throws Exception {
throw new UnsupportedOperationException(name + " does not support retrieving the next block median fee rate");
}
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
throw new UnsupportedOperationException(name + " does not support block summaries");
}
@ -196,6 +209,30 @@ public enum FeeRatesSource {
return httpClientService.requestJson(url, ThreeTierRates.class, null);
}
protected static Double requestNextBlockMedianFeeRate(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) {
log.info("Requesting next block median fee rate from " + url);
}
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolBlock[] mempoolBlocks = feeRatesSource.requestMempoolBlocks(url, httpClientService);
return mempoolBlocks.length > 0 ? mempoolBlocks[0].medianFee : null;
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving next block median fee rate from " + url, e);
} else {
log.warn("Error retrieving next block median fee rate from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolBlock[] requestMempoolBlocks(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolBlock[].class, null);
}
protected static BlockSummary requestBlockSummary(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) {
log.info("Requesting block summary from " + url);
@ -275,6 +312,27 @@ public enum FeeRatesSource {
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) {}
@ -285,6 +343,8 @@ public enum FeeRatesSource {
}
}
protected record MempoolBlock(Integer nTx, Double medianFee) {}
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();

View file

@ -1,11 +1,17 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.OsType;
import java.io.*;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.ProviderNotFoundException;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.EnumSet;
import java.util.Set;
/**
* A simple library class which helps with loading dynamic libraries stored in the
@ -111,9 +117,33 @@ public class NativeUtils {
String tempDir = System.getProperty("java.io.tmpdir");
File generatedDir = new File(tempDir, prefix + System.nanoTime());
if (!generatedDir.mkdir())
if(!createOwnerOnlyDirectory(generatedDir)) {
throw new IOException("Failed to create temp directory " + generatedDir.getName());
}
return generatedDir;
}
public static boolean createOwnerOnlyDirectory(File directory) throws IOException {
try {
if(OsType.getCurrent() == OsType.WINDOWS) {
Files.createDirectories(directory.toPath());
return true;
}
Files.createDirectories(directory.toPath(), PosixFilePermissions.asFileAttribute(getDirectoryOwnerOnlyPosixFilePermissions()));
return true;
} catch(UnsupportedOperationException e) {
return directory.mkdirs();
}
}
private static Set<PosixFilePermission> getDirectoryOwnerOnlyPosixFilePermissions() {
Set<PosixFilePermission> ownerOnly = EnumSet.noneOf(PosixFilePermission.class);
ownerOnly.add(PosixFilePermission.OWNER_READ);
ownerOnly.add(PosixFilePermission.OWNER_WRITE);
ownerOnly.add(PosixFilePermission.OWNER_EXECUTE);
return ownerOnly;
}
}

View file

@ -7,27 +7,30 @@ public class ServerCapability {
private final int maxTargetBlocks;
private final boolean supportsRecentMempool;
private final boolean supportsBlockStats;
private final boolean supportsUnsubscribe;
public ServerCapability(boolean supportsBatching) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast());
public ServerCapability(boolean supportsBatching, boolean supportsUnsubscribe) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsUnsubscribe);
}
public ServerCapability(boolean supportsBatching, int maxTargetBlocks) {
public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsUnsubscribe) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = false;
this.supportsBlockStats = false;
this.supportsUnsubscribe = supportsUnsubscribe;
}
public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats);
public ServerCapability(boolean supportsBatching, boolean supportsRecentMempool, boolean supportsBlockStats, boolean supportsUnsubscribe) {
this(supportsBatching, AppServices.TARGET_BLOCKS_RANGE.getLast(), supportsRecentMempool, supportsBlockStats, supportsUnsubscribe);
}
public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsRecentMempool, boolean supportsBlockStats) {
public ServerCapability(boolean supportsBatching, int maxTargetBlocks, boolean supportsRecentMempool, boolean supportsBlockStats, boolean supportsUnsubscribe) {
this.supportsBatching = supportsBatching;
this.maxTargetBlocks = maxTargetBlocks;
this.supportsRecentMempool = supportsRecentMempool;
this.supportsBlockStats = supportsBlockStats;
this.supportsUnsubscribe = supportsUnsubscribe;
}
public boolean supportsBatching() {
@ -45,4 +48,8 @@ public class ServerCapability {
public boolean supportsBlockStats() {
return supportsBlockStats;
}
public boolean supportsUnsubscribe() {
return supportsUnsubscribe;
}
}

View file

@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -38,16 +39,32 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
@Override
public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) {
if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER && Config.get().isLegacyServer()) {
return getLegacyServerVersion(transport, clientName);
}
try {
JsonRpcClient client = new JsonRpcClient(transport);
//Using 1.4 as the version number as EPS tries to parse this number to a float :(
return new RetryLogic<List<String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute());
client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, supportedVersions).execute());
} catch(JsonRpcException e) {
return getLegacyServerVersion(transport, clientName);
} catch(Exception e) {
throw new ElectrumServerRpcException("Error getting server version", e);
}
}
private List<String> getLegacyServerVersion(Transport transport, String clientName) {
try {
//Fallback to using 1.4 as the version number as EPS tries to parse this number to a float :(
JsonRpcClient client = new JsonRpcClient(transport);
return new RetryLogic<List<String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute());
} catch(Exception ex) {
throw new ElectrumServerRpcException("Error getting legacy server version", ex);
}
}
@Override
public String getServerBanner(Transport transport) {
try {

View file

@ -38,6 +38,6 @@ public class SubscriptionService {
existingStatuses.add(status);
}
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash)));
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash, status)));
}
}

View file

@ -16,6 +16,7 @@ import java.security.cert.Certificate;
public class TcpOverTlsTransport extends TcpTransport {
private static final Logger log = LoggerFactory.getLogger(TcpOverTlsTransport.class);
public static final int PAD_TO_MULTIPLE_OF_BYTES = 96;
protected final SSLSocketFactory sslSocketFactory;
@ -41,6 +42,24 @@ public class TcpOverTlsTransport extends TcpTransport {
sslSocketFactory = sslContext.getSocketFactory();
}
@Override
protected void writeRequest(String request) throws IOException {
int currentLength = request.length();
int targetLength;
if(currentLength % PAD_TO_MULTIPLE_OF_BYTES == 0) {
targetLength = currentLength;
} else {
targetLength = ((currentLength / PAD_TO_MULTIPLE_OF_BYTES) + 1) * PAD_TO_MULTIPLE_OF_BYTES;
}
int paddingNeeded = targetLength - currentLength;
if(paddingNeeded > 0) {
super.writeRequest(request + " ".repeat(paddingNeeded));
} else {
super.writeRequest(request);
}
}
private TrustManager[] getTrustManagers(File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
if(crtFile == null) {
return new TrustManager[] {

View file

@ -97,7 +97,7 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
}
}
private void writeRequest(String request) throws IOException {
protected void writeRequest(String request) throws IOException {
if(log.isTraceEnabled()) {
log.trace("Sending to electrum server at " + server + ": " + request);
}
@ -106,7 +106,7 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
throw new IllegalStateException("Socket connection has not been established.");
}
PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())));
PrintWriter out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)));
out.println(request);
out.flush();
}

View file

@ -149,6 +149,9 @@ public class BitcoindClient {
List<String> loadedWallets;
try {
loadedWallets = getBitcoindService().listWallets();
if(loadedWallets == null) {
throw new BitcoinRPCException("Wallet support must be enabled in Bitcoin Core");
}
legacyWalletExists = loadedWallets.contains(Bwt.DEFAULT_CORE_WALLET);
} catch(JsonRpcException e) {
if(e.getErrorMessage().getCode() == RPC_METHOD_NOT_FOUND) {

View file

@ -35,10 +35,11 @@ public class ElectrumServerService {
}
@JsonRpcMethod("server.version")
public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String protocolVersion) throws UnsupportedVersionException {
Version clientVersion = new Version(protocolVersion);
public List<String> getServerVersion(@JsonRpcParam("client_name") String clientName, @JsonRpcParam("protocol_version") String[] protocolVersion) throws UnsupportedVersionException {
String version = protocolVersion.length > 1 ? protocolVersion[1] : protocolVersion[0];
Version clientVersion = new Version(version);
if(clientVersion.compareTo(VERSION) < 0) {
throw new UnsupportedVersionException(protocolVersion);
throw new UnsupportedVersionException(version);
}
return List.of(Cormorant.SERVER_NAME + " " + SparrowWallet.APP_VERSION, VERSION.get());

View file

@ -40,6 +40,10 @@ public class Payjoin {
this.wallet = wallet;
this.psbt = psbt;
if(payjoinURI.getAddress() == null) {
throw new IllegalArgumentException("Payjoin URI must have an address");
}
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
if(psbtInput.getUtxo() == null) {
throw new IllegalArgumentException("Original PSBT for payjoin transaction must have non_witness_utxo or witness_utxo fields for all inputs");
@ -104,6 +108,9 @@ public class Payjoin {
} catch(PSBTParseException e) {
log.error("Error parsing received PSBT", e);
throw new PayjoinReceiverException("Payjoin receiver returned invalid PSBT", e);
} catch(PayjoinReceiverException e) {
log.error("Payjoin receiver error", e);
throw e;
} catch(Exception e) {
log.error("Payjoin error", e);
throw new PayjoinReceiverException("Payjoin error", e);

View file

@ -616,6 +616,7 @@ public class PayNymController {
List<byte[]> opReturns = List.of(blindedPaymentCode);
Double feeRate = AppServices.getDefaultFeeRate();
Double minimumFeeRate = AppServices.getMinimumFeeRate();
Double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
@ -623,7 +624,9 @@ public class PayNymController {
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true, false));
List<TxoFilter> txoFilters = List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(wallet));
return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs);
TransactionParameters params = new TransactionParameters(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(),
feeRate, minimumFeeRate, minRelayFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, true);
return wallet.createWalletTransaction(params);
}
private Map<BlockTransaction, WalletNode> getNotificationTransaction(PaymentCode externalPaymentCode) {

View file

@ -87,6 +87,8 @@ public class GeneralSettingsController extends SettingsDetailController {
config.setFeeRatesSource(feeRatesSource.getValue());
}
feeRatesSource.setCellFactory(_ -> new FeeRatesSourceListCell());
feeRatesSource.setButtonCell(feeRatesSource.getCellFactory().call(null));
feeRatesSource.valueProperty().addListener((observable, oldValue, newValue) -> {
config.setFeeRatesSource(newValue);
EventManager.get().post(new FeeRatesSourceChangedEvent(newValue));
@ -96,25 +98,8 @@ public class GeneralSettingsController extends SettingsDetailController {
currenciesLoadWarning.setVisible(false);
blockExplorers.setItems(getBlockExplorerList());
blockExplorers.setConverter(new StringConverter<>() {
@Override
public String toString(Server server) {
if(server == null || server == BlockExplorer.NONE.getServer()) {
return "None";
}
if(server == CUSTOM_BLOCK_EXPLORER) {
return "Custom...";
}
return server.getHost();
}
@Override
public Server fromString(String string) {
return null;
}
});
blockExplorers.setCellFactory(_ -> new BlockExplorerListCell());
blockExplorers.setButtonCell(blockExplorers.getCellFactory().call(null));
blockExplorers.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue == CUSTOM_BLOCK_EXPLORER) {
@ -258,14 +243,50 @@ public class GeneralSettingsController extends SettingsDetailController {
fiatCurrency.valueProperty().addListener(fiatCurrencyListener);
}
private static class FeeRatesSourceListCell extends ListCell<FeeRatesSource> {
@Override
protected void updateItem(FeeRatesSource item, boolean empty) {
super.updateItem(item, empty);
if(empty || item == null) {
setText(null);
setGraphic(null);
} else {
setText(item.toString());
setGraphic(item.getSVGImage());
setGraphicTextGap(8.0d);
}
}
}
private static class BlockExplorerListCell extends ListCell<Server> {
@Override
protected void updateItem(Server server, boolean empty) {
super.updateItem(server, empty);
if(empty || server == null || server == BlockExplorer.NONE.getServer()) {
setText("None");
setGraphic(null);
} else if(server == CUSTOM_BLOCK_EXPLORER) {
setText("Custom...");
setGraphic(null);
} else {
setText(server.getHost());
setGraphic(BlockExplorer.getSVGImage(server));
setGraphicTextGap(8.0d);
}
}
}
private static class ExchangeSourceButtonCell extends ListCell<ExchangeSource> {
@Override
protected void updateItem(ExchangeSource exchangeSource, boolean empty) {
super.updateItem(exchangeSource, empty);
if(exchangeSource == null || empty) {
setText("");
setGraphic(null);
} else {
setText(exchangeSource.getName());
setGraphic(exchangeSource.getSVGImage());
setGraphicTextGap(8.0d);
}
}
}
@ -276,12 +297,15 @@ public class GeneralSettingsController extends SettingsDetailController {
super.updateItem(exchangeSource, empty);
if(exchangeSource == null || empty) {
setText("");
setGraphic(null);
} else {
String text = exchangeSource.getName();
if(exchangeSource.getDescription() != null && !exchangeSource.getDescription().isEmpty()) {
text += " (" + exchangeSource.getDescription() + ")";
}
setText(text);
setGraphic(exchangeSource.getSVGImage());
setGraphicTextGap(8.0d);
}
}
}

View file

@ -4,9 +4,12 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
@ -57,7 +60,6 @@ import tornadofx.control.Fieldset;
import com.google.common.eventbus.Subscribe;
import tornadofx.control.Form;
import javax.swing.text.html.Option;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
@ -180,6 +182,15 @@ public class HeadersController extends TransactionFormController implements Init
@FXML
private CopyableLabel blockTimestamp;
@FXML
private Field signedByField;
@FXML
private CopyableLabel signedBy;
@FXML
private Form blockchainSpacerForm;
@FXML
private Form signingWalletForm;
@ -451,6 +462,7 @@ public class HeadersController extends TransactionFormController implements Init
headersForm.setWalletTransaction(getWalletTransaction(headersForm.getInputTransactions()));
blockchainForm.managedProperty().bind(blockchainForm.visibleProperty());
blockchainSpacerForm.managedProperty().bind(blockchainForm.managedProperty());
signingWalletForm.managedProperty().bind(signingWalletForm.visibleProperty());
sigHashForm.managedProperty().bind(sigHashForm.visibleProperty());
@ -636,24 +648,27 @@ public class HeadersController extends TransactionFormController implements Init
}
List<Payment> payments = new ArrayList<>();
List<WalletTransaction.Output> outputs = new ArrayList<>();
Map<WalletNode, Long> changeMap = new LinkedHashMap<>();
Map<Script, WalletNode> receiveOutputScripts = wallet.getWalletOutputScripts(KeyPurpose.RECEIVE);
Map<Script, WalletNode> changeOutputScripts = wallet.getWalletOutputScripts(wallet.getChangeKeyPurpose());
for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) {
WalletNode changeNode = changeOutputScripts.get(txOutput.getScript());
if(changeNode != null) {
if(headersForm.getTransaction().getOutputs().size() == 4 && headersForm.getTransaction().getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) {
if(selectedTxos.values().stream().allMatch(Objects::nonNull)) {
payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX));
payments.add(new WalletNodePayment(changeNode, ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX));
} else {
payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX));
payments.add(new WalletNodePayment(changeNode, ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX));
}
} else {
if(changeMap.containsKey(changeNode)) {
payments.add(new Payment(txOutput.getScript().getToAddress(), headersForm.getName(), txOutput.getValue(), false, Payment.Type.DEFAULT));
payments.add(new WalletNodePayment(changeNode, headersForm.getName(), txOutput.getValue(), false, Payment.Type.DEFAULT));
} else {
changeMap.put(changeNode, txOutput.getValue());
}
}
outputs.add(new WalletTransaction.ChangeOutput(txOutput, changeNode, txOutput.getValue()));
} else {
Payment.Type paymentType = Payment.Type.DEFAULT;
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
@ -664,24 +679,44 @@ public class HeadersController extends TransactionFormController implements Init
BlockTransactionHashIndex receivedTxo = walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txOutput.getHash()) && txo.getIndex() == txOutput.getIndex()).findFirst().orElse(null);
String label = headersForm.getName() == null || (headersForm.getName().startsWith("[") && headersForm.getName().endsWith("]") && headersForm.getName().length() == 8) ? null : headersForm.getName();
try {
Payment payment = new Payment(txOutput.getScript().getToAddresses()[0], receivedTxo != null ? receivedTxo.getLabel() : label, txOutput.getValue(), false, paymentType);
Address address = txOutput.getScript().getToAddress();
WalletNode receiveNode = receiveOutputScripts.get(txOutput.getScript());
SilentPaymentAddress silentPaymentAddress = headersForm.getSilentPaymentAddress(txOutput);
label = receivedTxo != null ? receivedTxo.getLabel() : label;
if(address != null || silentPaymentAddress != null) {
Payment payment;
if(silentPaymentAddress != null) {
payment = new SilentPayment(silentPaymentAddress, address, label, txOutput.getValue(), false);
} else if(receiveNode != null) {
payment = new WalletNodePayment(receiveNode, label, txOutput.getValue(), false, paymentType);
} else {
payment = new Payment(address, label, txOutput.getValue(), false, paymentType);
}
WalletTransaction createdTx = AppServices.get().getCreatedTransaction(selectedTxos.keySet());
if(createdTx != null) {
Optional<String> optLabel = createdTx.getPayments().stream().filter(pymt -> pymt.getAddress().equals(payment.getAddress()) && pymt.getAmount() == payment.getAmount()).map(Payment::getLabel).findFirst();
Optional<String> optLabel = createdTx.getPayments().stream()
.filter(pymt -> (pymt instanceof SilentPayment silentPayment ? silentPayment.getSilentPaymentAddress().equals(silentPaymentAddress) :
pymt.getAddress().equals(payment.getAddress())) && pymt.getAmount() == payment.getAmount()).map(Payment::getLabel).findFirst();
if(optLabel.isPresent()) {
payment.setLabel(optLabel.get());
outputIndexLabels.put(txOutput.getIndex(), optLabel.get());
}
}
payments.add(payment);
} catch(Exception e) {
//ignore
if(payment instanceof SilentPayment silentPayment) {
outputs.add(new WalletTransaction.SilentPaymentOutput(txOutput, silentPayment));
} else if(payment instanceof WalletNodePayment walletNodePayment) {
outputs.add(new WalletTransaction.ConsolidationOutput(txOutput, walletNodePayment, walletNodePayment.getAmount()));
} else {
outputs.add(new WalletTransaction.PaymentOutput(txOutput, payment));
}
} else {
outputs.add(new WalletTransaction.NonAddressOutput(txOutput));
}
}
}
return new WalletTransaction(wallet, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, changeMap, fee.getValue(), walletInputTransactions);
return new WalletTransaction(wallet, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, outputs, changeMap, fee.getValue(), walletInputTransactions);
} else {
Map<BlockTransactionHashIndex, WalletNode> selectedTxos = headersForm.getTransaction().getInputs().stream()
.collect(Collectors.toMap(txInput -> getBlockTransactionInput(inputTransactions, txInput),
@ -691,16 +726,25 @@ public class HeadersController extends TransactionFormController implements Init
selectedTxos.entrySet().forEach(entry -> entry.setValue(null));
List<Payment> payments = new ArrayList<>();
List<WalletTransaction.Output> outputs = new ArrayList<>();
for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) {
try {
Address address = txOutput.getScript().getToAddress();
SilentPaymentAddress silentPaymentAddress = headersForm.getSilentPaymentAddress(txOutput);
BlockTransactionHashIndex receivedTxo = getBlockTransactionOutput(txOutput);
payments.add(new Payment(txOutput.getScript().getToAddresses()[0], receivedTxo != null ? receivedTxo.getLabel() : null, txOutput.getValue(), false));
} catch(Exception e) {
//ignore
String label = receivedTxo != null ? receivedTxo.getLabel() : null;
if(address != null || silentPaymentAddress != null) {
Payment payment = (silentPaymentAddress == null ?
new Payment(address, label, txOutput.getValue(), false) :
new SilentPayment(silentPaymentAddress, address, label, txOutput.getValue(), false));
payments.add(payment);
outputs.add(payment instanceof SilentPayment silentPayment ? new WalletTransaction.SilentPaymentOutput(txOutput, silentPayment) :
new WalletTransaction.PaymentOutput(txOutput, payment));
} else {
outputs.add(new WalletTransaction.NonAddressOutput(txOutput));
}
}
return new WalletTransaction(null, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, Collections.emptyMap(), fee.getValue(), inputTransactions);
return new WalletTransaction(null, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, outputs, Collections.emptyMap(), fee.getValue(), inputTransactions);
}
}
@ -774,6 +818,7 @@ public class HeadersController extends TransactionFormController implements Init
blockHeightField.managedProperty().bind(blockHeightField.visibleProperty());
blockTimestampField.managedProperty().bind(blockTimestampField.visibleProperty());
signedByField.managedProperty().bind(signedByField.visibleProperty());
if(blockTransaction.getHeight() > 0) {
blockHeightField.setVisible(true);
@ -791,6 +836,19 @@ public class HeadersController extends TransactionFormController implements Init
} else {
blockTimestampField.setVisible(false);
}
if(headersForm.getWalletTransaction() != null && headersForm.getWalletTransaction().getWallet() != null
&& headersForm.getWalletTransaction().getWallet().getPolicyType() == PolicyType.MULTI
&& headersForm.getWalletTransaction().getWallet().getDefaultPolicy().getNumSignaturesRequired() < headersForm.getWalletTransaction().getWallet().getKeystores().size()) {
signedByField.setVisible(true);
Wallet wallet = headersForm.getWalletTransaction().getWallet();
Map<TransactionInput, Map<TransactionSignature, Keystore>> signedKeystores = wallet.getSignedKeystores(blockTransaction.getTransaction());
StringJoiner joiner = new StringJoiner(", ");
signedKeystores.values().stream().flatMap(map -> map.values().stream()).distinct().forEach(keystore -> joiner.add(keystore.getLabel()));
signedBy.setText(joiner.toString());
} else {
signedByField.setVisible(false);
}
}
private void initializeSignButton(Wallet signingWallet) {
@ -927,7 +985,7 @@ public class HeadersController extends TransactionFormController implements Init
//Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning
boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType());
byte[] psbtBytes = headersForm.getPsbt().serialize(true, includeNonWitnessUtxos);
byte[] psbtBytes = headersForm.getPsbt().getForExport().serialize(true, includeNonWitnessUtxos);
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = addBbqrOption ? new BBQR(BBQRType.PSBT, psbtBytes) : null;
@ -1010,7 +1068,7 @@ public class HeadersController extends TransactionFormController implements Init
}
try(FileOutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(headersForm.getPsbt().serialize());
outputStream.write(headersForm.getPsbt().getForExport().serialize());
} catch(IOException e) {
log.error("Error saving PSBT", e);
AppServices.showErrorDialog("Error saving PSBT", "Cannot write to " + file.getAbsolutePath());
@ -1067,7 +1125,12 @@ public class HeadersController extends TransactionFormController implements Init
private void signUnencryptedKeystores(Wallet unencryptedWallet) {
try {
unencryptedWallet.sign(headersForm.getPsbt());
Map<PSBTInput, WalletNode> signingNodes = unencryptedWallet.getSigningNodes(headersForm.getPsbt());
List<SilentPayment> silentPayments = unencryptedWallet.computeSilentPaymentOutputs(headersForm.getPsbt(), signingNodes);
if(!silentPayments.isEmpty()) {
EventManager.get().post(new TransactionOutputsChangedEvent(headersForm.getTransaction()));
}
unencryptedWallet.sign(signingNodes);
updateSignedKeystores(headersForm.getSigningWallet());
} catch(Exception e) {
log.warn("Failed to Sign", e);
@ -1139,7 +1202,7 @@ public class HeadersController extends TransactionFormController implements Init
if(fee.getValue() > 0) {
double feeRateAmt = fee.getValue() / headersForm.getTransaction().getVirtualSize();
if(feeRateAmt > AppServices.LONG_FEE_RATES_RANGE.get(AppServices.LONG_FEE_RATES_RANGE.size() - 1)) {
if(feeRateAmt > AppServices.getLongFeeRatesRange().getLast()) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Very high fee rate!",
"This transaction pays a very high fee rate of " + String.format("%.0f", feeRateAmt) + " sats/vB.\n\nBroadcast this transaction?", ButtonType.YES, ButtonType.NO);
if(optType.isPresent() && optType.get() == ButtonType.NO) {
@ -1225,9 +1288,17 @@ public class HeadersController extends TransactionFormController implements Init
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
if(failMessage.startsWith("min relay fee not met")) {
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum " + format.getCurrencyFormat().format(AppServices.getMinimumRelayFeeRate()) + " sats/vB. " +
if(AppServices.getServerMinimumRelayFeeRate() != null && !AppServices.getServerMinimumRelayFeeRate().equals(AppServices.getMinimumRelayFeeRate())) {
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum configured relay fee rate for the server of " +
format.getCurrencyFormat().format(AppServices.getServerMinimumRelayFeeRate()) + " sats/vB.");
} else {
Double minRelayFeeRate = AppServices.getServerMinimumRelayFeeRate() != null ? AppServices.getServerMinimumRelayFeeRate() : AppServices.getMinimumRelayFeeRate();
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum " + format.getCurrencyFormat().format(minRelayFeeRate) + " sats/vB. " +
"This usually happens because a keystore has created a signature that is larger than necessary.\n\n" +
"You can solve this by recreating the transaction with a slightly increased fee rate.");
}
} else if(failMessage.startsWith("dust")) {
AppServices.showErrorDialog("Error broadcasting transaction", "The server will not accept this transaction for broadcast due to its configured dust limit policy.");
} else if(failMessage.startsWith("bad-txns-inputs-missingorspent")) {
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error indicating some or all of the UTXOs this transaction is spending are missing or have already been spent.");
} else if(failMessage.contains("mempool min fee not met")) {
@ -1428,6 +1499,7 @@ public class HeadersController extends TransactionFormController implements Init
errorGlyph.getStyleClass().add("failure");
blockHeightField.setVisible(false);
blockTimestampField.setVisible(false);
signedByField.setVisible(false);
}
}
@ -1559,6 +1631,23 @@ public class HeadersController extends TransactionFormController implements Init
signButtonBox.setVisible(false);
broadcastButtonBox.setVisible(true);
if(Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) {
if(Config.get().getConnectToBroadcast() == null) {
Platform.runLater(() -> {
ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to broadcast?", "Connect to the configured server to broadcast the transaction?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = confirmationAlert.showAndWait();
if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
Config.get().setConnectToBroadcast(optType.get() == ButtonType.YES);
}
if(optType.isPresent() && optType.get() == ButtonType.YES) {
EventManager.get().post(new RequestConnectEvent());
}
});
} else if(Config.get().getConnectToBroadcast()) {
Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent()));
}
}
}
}
@ -1570,6 +1659,13 @@ public class HeadersController extends TransactionFormController implements Init
}
}
@Subscribe
public void transactionOutputsChanged(TransactionOutputsChangedEvent event) {
if(event.getTransaction().equals(headersForm.getTransaction())) {
headersForm.setWalletTransaction(getWalletTransaction(headersForm.getInputTransactions()));
}
}
@Subscribe
public void transactionExtracted(TransactionExtractedEvent event) {
if(event.getPsbt().equals(headersForm.getPsbt())) {

View file

@ -337,7 +337,7 @@ public class InputController extends TransactionFormController implements Initia
}
} else {
if(txInput.isAbsoluteTimeLocked()) {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_LOCKTIME_DISABLED - 1);
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED);
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));
}
@ -389,7 +389,7 @@ public class InputController extends TransactionFormController implements Initia
if(rbf.selectedProperty().getValue()) {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_ENABLED);
} else {
txInput.setSequenceNumber(TransactionInput.SEQUENCE_LOCKTIME_DISABLED - 1);
txInput.setSequenceNumber(TransactionInput.SEQUENCE_RBF_DISABLED);
}
if(old_toggle != null) {
EventManager.get().post(new TransactionChangedEvent(transaction));

View file

@ -5,14 +5,12 @@ import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.PSBTReorderedEvent;
import com.sparrowwallet.sparrow.event.UnitFormatChangedEvent;
import com.sparrowwallet.sparrow.event.BlockTransactionOutputsFetchedEvent;
import com.sparrowwallet.sparrow.event.ViewTransactionEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
@ -70,20 +68,7 @@ public class OutputController extends TransactionFormController implements Initi
updateOutputLegendFromWallet(txOutput, walletTransaction != null ? walletTransaction.getWallet() : null);
});
updateOutputLegendFromWallet(txOutput, outputForm.getWallet());
value.setValue(txOutput.getValue());
to.setVisible(false);
try {
Address[] addresses = txOutput.getScript().getToAddresses();
to.setVisible(true);
if(addresses.length == 1) {
address.setAddress(addresses[0]);
} else {
address.setText("multiple addresses");
}
} catch(NonStandardScriptException e) {
//ignore
}
updateSends(txOutput);
spentField.managedProperty().bind(spentField.visibleProperty());
spentByField.managedProperty().bind(spentByField.visibleProperty());
@ -98,6 +83,32 @@ public class OutputController extends TransactionFormController implements Initi
}
initializeScriptField(scriptPubKeyArea);
updateScriptPubKey(txOutput);
}
private void updateSends(TransactionOutput txOutput) {
value.setValue(txOutput.getValue());
to.setVisible(false);
Address toAddress = txOutput.getScript().getToAddress();
SilentPaymentAddress silentPaymentAddress = outputForm.getSilentPaymentAddress(txOutput);
if(toAddress != null) {
to.setVisible(true);
address.setAddress(toAddress);
} else if(silentPaymentAddress != null) {
to.setVisible(true);
address.setText(silentPaymentAddress.toAbbreviatedString());
} else {
try {
txOutput.getScript().getToAddresses();
to.setVisible(true);
address.setText("multiple addresses");
} catch(NonStandardScriptException e) {
//ignore
}
}
}
private void updateScriptPubKey(TransactionOutput txOutput) {
scriptPubKeyArea.clear();
scriptPubKeyArea.appendScript(txOutput.getScript(), null, null);
}
@ -115,11 +126,14 @@ public class OutputController extends TransactionFormController implements Initi
WalletTransaction.Output output = outputs.get(outputForm.getIndex());
if(output instanceof WalletTransaction.NonAddressOutput) {
outputFieldset.setText(baseText);
} else if(output instanceof WalletTransaction.SilentPaymentOutput) {
outputFieldset.setText(baseText + " - Silent Payment");
} else if(output instanceof WalletTransaction.ConsolidationOutput) {
outputFieldset.setText(baseText + " - Consolidation");
} else if(output instanceof WalletTransaction.PaymentOutput paymentOutput) {
Payment payment = paymentOutput.getPayment();
Wallet toWallet = walletTx.getToWallet(AppServices.get().getOpenWallets().keySet(), payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
outputFieldset.setText(baseText + (toWallet == null ? (toNode != null ? " - Consolidation" : " - Payment") : " - Received to " + toWallet.getFullDisplayName()));
outputFieldset.setText(baseText + (toWallet == null ? " - Payment" : " - Received to " + toWallet.getFullDisplayName()));
} else if(output instanceof WalletTransaction.ChangeOutput changeOutput) {
outputFieldset.setText(baseText + " - Change to " + changeOutput.getWalletNode().toString());
} else {
@ -206,4 +220,12 @@ public class OutputController extends TransactionFormController implements Initi
updateOutputLegendFromWallet(outputForm.getTransactionOutput(), null);
}
}
@Subscribe
public void transactionOutputsChanged(TransactionOutputsChangedEvent event) {
if(event.getTransaction().equals(outputForm.getTransaction())) {
updateSends(outputForm.getTransactionOutput());
updateScriptPubKey(outputForm.getTransactionOutput());
}
}
}

View file

@ -89,7 +89,11 @@ public class OutputForm extends IndexedTransactionForm {
}
} else if(output instanceof WalletTransaction.PaymentOutput paymentOutput) {
Payment payment = paymentOutput.getPayment();
return new Label(payment.getLabel() != null && payment.getType() != Payment.Type.FAKE_MIX && payment.getType() != Payment.Type.MIX ? payment.getLabel() : payment.getAddress().toString(),
return new Label(payment.getLabel() != null && payment.getType() != Payment.Type.FAKE_MIX && payment.getType() != Payment.Type.MIX ? payment.getLabel() : payment.toString(),
GlyphUtils.getOutputGlyph(getWalletTransaction(), payment));
} else if(output instanceof WalletTransaction.ConsolidationOutput consolidationOutput) {
Payment payment = consolidationOutput.getWalletNodePayment();
return new Label(payment.getLabel() != null && payment.getType() != Payment.Type.FAKE_MIX && payment.getType() != Payment.Type.MIX ? payment.getLabel() : payment.toString(),
GlyphUtils.getOutputGlyph(getWalletTransaction(), payment));
} else if(output instanceof WalletTransaction.ChangeOutput changeOutput) {
return new Label("Change", GlyphUtils.getChangeGlyph());

View file

@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.CopyableCoinLabel;
import com.sparrowwallet.sparrow.control.CopyableLabel;
import com.sparrowwallet.sparrow.event.TransactionOutputsChangedEvent;
import com.sparrowwallet.sparrow.event.UnitFormatChangedEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
@ -60,4 +61,11 @@ public class OutputsController extends TransactionFormController implements Init
public void unitFormatChanged(UnitFormatChangedEvent event) {
total.refresh(event.getUnitFormat(), event.getBitcoinUnit());
}
@Subscribe
public void transactionOutputsChanged(TransactionOutputsChangedEvent event) {
if(event.getTransaction().equals(outputsForm.getTransaction())) {
updatePieData(outputsPie, outputsForm.getTransaction().getOutputs());
}
}
}

View file

@ -3,6 +3,8 @@ package com.sparrowwallet.sparrow.transaction;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.beans.property.SimpleObjectProperty;
@ -193,4 +195,16 @@ public class TransactionData {
public Wallet getWallet() {
return getSigningWallet() != null ? getSigningWallet() : (getWalletTransaction() != null ? getWalletTransaction().getWallet() : null);
}
protected SilentPaymentAddress getSilentPaymentAddress(TransactionOutput txOutput) {
if(getPsbt() != null && txOutput.getParent() != null) {
for(PSBTOutput psbtOutput : getPsbt().getPsbtOutputs()) {
if(psbtOutput.getOutput().getIndex() == txOutput.getIndex() && psbtOutput.getSilentPaymentAddress() != null) {
return psbtOutput.getSilentPaymentAddress();
}
}
}
return null;
}
}

View file

@ -2,8 +2,10 @@ package com.sparrowwallet.sparrow.transaction;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.protocol.TransactionSignature;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.beans.property.SimpleObjectProperty;
@ -112,6 +114,10 @@ public abstract class TransactionForm {
return txdata.getWallet();
}
public SilentPaymentAddress getSilentPaymentAddress(TransactionOutput output) {
return txdata.getSilentPaymentAddress(output);
}
public boolean isEditable() {
if(getBlockTransaction() != null) {
return false;

View file

@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.BaseController;
import com.sparrowwallet.sparrow.EventManager;
@ -33,17 +34,7 @@ public abstract class TransactionFormController extends BaseController {
long totalAmt = 0;
for(int i = 0; i < outputs.size(); i++) {
TransactionOutput output = outputs.get(i);
String name = "#" + i;
try {
Address[] addresses = output.getScript().getToAddresses();
if(addresses.length == 1) {
name = name + " " + addresses[0].getAddress();
} else {
name = name + " [" + addresses[0].getAddress() + ",...]";
}
} catch(NonStandardScriptException e) {
//ignore
}
String name = getPieDataName(i, output);
totalAmt += output.getValue();
outputsPieData.add(new PieChart.Data(name, output.getValue()));
@ -52,6 +43,34 @@ public abstract class TransactionFormController extends BaseController {
addPieData(pie, outputsPieData);
}
protected void updatePieData(PieChart pie, List<TransactionOutput> outputs) {
for(int i = 0; i < outputs.size(); i++) {
TransactionOutput output = outputs.get(i);
String name = getPieDataName(i, output);
pie.getData().get(i).setName(name);
}
}
private String getPieDataName(int i, TransactionOutput output) {
String name = "#" + i;
Address address = output.getScript().getToAddress();
SilentPaymentAddress silentPaymentAddress = getTransactionForm().getSilentPaymentAddress(output);
if(address != null) {
name = name + " " + address.getAddress();
} else if(silentPaymentAddress != null) {
name = name + " " + silentPaymentAddress.toAbbreviatedString();
} else {
try {
Address[] addresses = output.getScript().getToAddresses();
name = name + " [" + addresses[0].getAddress() + ",...]";
} catch(NonStandardScriptException e) {
//ignore
}
}
return name;
}
protected void addCoinbasePieData(PieChart pie, long value) {
ObservableList<PieChart.Data> outputsPieData = FXCollections.observableList(List.of(new PieChart.Data("Coinbase", value)));
addPieData(pie, outputsPieData);

View file

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.wallet;
import com.google.common.base.Throwables;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
@ -9,15 +10,15 @@ import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
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.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@ -25,6 +26,8 @@ import com.sparrowwallet.sparrow.io.CardApi;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.sparrow.paynym.PayNym;
import com.sparrowwallet.sparrow.paynym.PayNymDialog;
import javafx.application.Platform;
@ -36,21 +39,32 @@ import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
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.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.*;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@ -129,8 +143,14 @@ public class PaymentController extends WalletFormController implements Initializ
}
};
private final ObjectProperty<WalletNode> consolidationNodeProperty = new SimpleObjectProperty<>();
private final ObjectProperty<PayNym> payNymProperty = new SimpleObjectProperty<>();
private final ObjectProperty<SilentPaymentAddress> silentPaymentAddressProperty = new SimpleObjectProperty<>();
private final ObjectProperty<DnsPayment> dnsPaymentProperty = new SimpleObjectProperty<>();
private static final Wallet payNymWallet = new Wallet() {
@Override
public String getFullDisplayName() {
@ -145,6 +165,127 @@ public class PaymentController extends WalletFormController implements Initializ
}
};
private final ChangeListener<String> addressListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
address.leftProperty().set(null);
if(consolidationNodeProperty.get() != null && !newValue.equals(consolidationNodeProperty.get().getAddress().toString())) {
consolidationNodeProperty.set(null);
}
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
if(dnsPaymentProperty.get() != null && !newValue.equals(dnsPaymentProperty.get().hrn())) {
dnsPaymentProperty.set(null);
}
if(silentPaymentAddressProperty.get() != null && !newValue.equals(silentPaymentAddressProperty.get().getAddress())) {
silentPaymentAddressProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
return;
} catch(Exception e) {
//ignore, not a URI
}
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(newValue);
if(optDnsPaymentHrn.isPresent()) {
String dnsPaymentHrn = optDnsPaymentHrn.get();
DnsPayment cachedDnsPayment = DnsPaymentCache.getDnsPayment(dnsPaymentHrn);
if(cachedDnsPayment != null) {
setDnsPayment(cachedDnsPayment);
return;
}
if(Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) {
if(Config.get().getConnectToResolve() == null || Config.get().getConnectToResolve() == Boolean.FALSE) {
Platform.runLater(() -> {
ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to resolve?", "You are currently offline. Connect to resolve the address?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = confirmationAlert.showAndWait();
if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
Config.get().setConnectToResolve(optType.get() == ButtonType.YES);
}
if(optType.isPresent() && optType.get() == ButtonType.YES) {
EventManager.get().post(new RequestConnectEvent());
}
});
} else {
Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent()));
}
return;
}
DnsPaymentService dnsPaymentService = new DnsPaymentService(dnsPaymentHrn);
dnsPaymentService.setOnSucceeded(_ -> dnsPaymentService.getValue().ifPresent(dnsPayment -> setDnsPayment(dnsPayment)));
dnsPaymentService.setOnFailed(failEvent -> {
if(failEvent.getSource().getException() != null && !(failEvent.getSource().getException().getCause() instanceof TimeoutException)) {
AppServices.showErrorDialog("Validation failed for " + dnsPaymentHrn, Throwables.getRootCause(failEvent.getSource().getException()).getMessage());
}
});
dnsPaymentService.start();
return;
}
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
try {
PaymentCode paymentCode = new PaymentCode(newValue);
Wallet recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, sendController.getWalletForm().getWallet().getScriptType());
if(recipientBip47Wallet == null && sendController.getWalletForm().getWallet().getScriptType() != ScriptType.P2PKH) {
recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, ScriptType.P2PKH);
}
if(recipientBip47Wallet != null) {
PayNym payNym = PayNym.fromWallet(recipientBip47Wallet);
Platform.runLater(() -> setPayNym(payNym));
} else if(!paymentCode.equals(sendController.getWalletForm().getWallet().getPaymentCode())) {
ButtonType previewType = new ButtonType("Preview Transaction", ButtonBar.ButtonData.YES);
Optional<ButtonType> optButton = AppServices.showAlertDialog("Send notification transaction?", "This payment code is not yet linked with a notification transaction. Send a notification transaction?", Alert.AlertType.CONFIRMATION, ButtonType.CANCEL, previewType);
if(optButton.isPresent() && optButton.get() == previewType) {
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + paymentCode.toAbbreviatedString(), MINIMUM_P2PKH_OUTPUT_SATS, false);
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(sendController.getWalletForm().getWallet(), List.of(payment), List.of(new byte[80]), paymentCode)));
} else {
Platform.runLater(() -> address.setText(""));
}
}
} catch(Exception e) {
//ignore, not a payment code
}
}
try {
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(newValue);
setSilentPaymentAddress(silentPaymentAddress);
} catch(Exception e) {
//ignore, not a silent payment address
}
try {
Address toAddress = Address.fromString(newValue);
WalletNode walletNode = sendController.getWalletNode(toAddress);
if(walletNode != null) {
consolidationNodeProperty.set(walletNode);
}
label.requestFocus();
} catch(Exception e) {
//ignore, not an address
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@ -210,6 +351,28 @@ public class PaymentController extends WalletFormController implements Initializ
revalidateAmount();
});
silentPaymentAddressProperty.addListener((observable, oldValue, silentPaymentAddress) -> {
revalidateAmount();
});
dnsPaymentProperty.addListener((observable, oldValue, dnsPayment) -> {
if(dnsPayment != null) {
MenuItem copyMenuItem = new MenuItem("Copy URI");
copyMenuItem.setOnAction(e -> {
ClipboardContent content = new ClipboardContent();
content.putString(dnsPayment.bitcoinURI().toURIString());
Clipboard.getSystemClipboard().setContent(content);
});
address.setContextMenu(address.getCustomContextMenu(List.of(copyMenuItem)));
} else {
address.setContextMenu(address.getCustomContextMenu(Collections.emptyList()));
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
});
address.setTextFormatter(new TextFormatter<>(change -> {
String controlNewText = change.getControlNewText();
if(!controlNewText.equals(controlNewText.trim())) {
@ -222,55 +385,8 @@ public class PaymentController extends WalletFormController implements Initializ
return change;
}));
address.textProperty().addListener((observable, oldValue, newValue) -> {
address.leftProperty().set(null);
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
return;
} catch(Exception e) {
//ignore, not a URI
}
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
try {
PaymentCode paymentCode = new PaymentCode(newValue);
Wallet recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, sendController.getWalletForm().getWallet().getScriptType());
if(recipientBip47Wallet == null && sendController.getWalletForm().getWallet().getScriptType() != ScriptType.P2PKH) {
recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, ScriptType.P2PKH);
}
if(recipientBip47Wallet != null) {
PayNym payNym = PayNym.fromWallet(recipientBip47Wallet);
Platform.runLater(() -> setPayNym(payNym));
} else if(!paymentCode.equals(sendController.getWalletForm().getWallet().getPaymentCode())) {
ButtonType previewType = new ButtonType("Preview Transaction", ButtonBar.ButtonData.YES);
Optional<ButtonType> optButton = AppServices.showAlertDialog("Send notification transaction?", "This payment code is not yet linked with a notification transaction. Send a notification transaction?", Alert.AlertType.CONFIRMATION, ButtonType.CANCEL, previewType);
if(optButton.isPresent() && optButton.get() == previewType) {
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + paymentCode.toAbbreviatedString(), MINIMUM_P2PKH_OUTPUT_SATS, false);
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(sendController.getWalletForm().getWallet(), List.of(payment), List.of(new byte[80]), paymentCode)));
} else {
Platform.runLater(() -> address.setText(""));
}
}
} catch(Exception e) {
//ignore, not a payment code
}
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
});
address.textProperty().addListener(addressListener);
address.setContextMenu(address.getCustomContextMenu(Collections.emptyList()));
label.textProperty().addListener((observable, oldValue, newValue) -> {
maxButton.setDisable(!isMaxButtonEnabled());
@ -328,6 +444,37 @@ public class PaymentController extends WalletFormController implements Initializ
}
}
public void setDnsPayment(DnsPayment dnsPayment) {
if(dnsPayment.hasAddress()) {
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment);
} else if(dnsPayment.hasSilentPaymentAddress()) {
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), dnsPayment);
setSilentPaymentAddress(dnsPayment.bitcoinURI().getSilentPaymentAddress());
} else {
AppServices.showWarningDialog("No Address Provided", "The DNS payment instruction for " + dnsPayment.hrn() + " resolved correctly but did not contain a bitcoin address.");
return;
}
dnsPaymentProperty.set(dnsPayment);
address.setText(dnsPayment.hrn());
revalidate(address, addressListener);
address.leftProperty().set(getBitcoinCharacter());
if(label.getText().isEmpty() || (label.getText().startsWith("") && !label.getText().contains(" "))) {
label.setText(dnsPayment.toString());
}
label.requestFocus();
}
private void setSilentPaymentAddress(SilentPaymentAddress silentPaymentAddress) {
if(!sendController.getWalletForm().getWallet().canSendSilentPayments()) {
Platform.runLater(() -> AppServices.showErrorDialog("Silent Payments Unsupported", "This wallet does not support sending silent payments. Use a single signature software wallet."));
return;
}
silentPaymentAddressProperty.set(silentPaymentAddress);
label.requestFocus();
}
private void updateOpenWallets() {
updateOpenWallets(AppServices.get().getOpenWallets().keySet());
}
@ -399,6 +546,16 @@ public class PaymentController extends WalletFormController implements Initializ
}
private Address getRecipientAddress() throws InvalidAddressException {
SilentPaymentAddress silentPaymentAddress = silentPaymentAddressProperty.get();
if(silentPaymentAddress != null) {
return SilentPayment.getDummyAddress();
}
DnsPayment dnsPayment = dnsPaymentProperty.get();
if(dnsPayment != null && dnsPayment.hasAddress()) {
return dnsPayment.bitcoinURI().getAddress();
}
PayNym payNym = payNymProperty.get();
if(payNym == null) {
return Address.fromString(address.getText());
@ -516,7 +673,17 @@ public class PaymentController extends WalletFormController implements Initializ
Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats();
if(!label.getText().isEmpty() && value != null && value >= getRecipientDustThreshold()) {
Payment payment = new Payment(recipientAddress, label.getText(), value, sendAll);
Payment payment;
SilentPaymentAddress silentPaymentAddress = silentPaymentAddressProperty.get();
WalletNode consolidationNode = consolidationNodeProperty.get();
if(silentPaymentAddress != null) {
payment = new SilentPayment(silentPaymentAddress, label.getText(), value, sendAll);
} else if(consolidationNode != null) {
payment = new WalletNodePayment(consolidationNode, label.getText(), value, sendAll);
} else {
payment = new Payment(recipientAddress, label.getText(), value, sendAll);
}
if(address.getUserData() != null) {
payment.setType((Payment.Type)address.getUserData());
}
@ -533,7 +700,14 @@ public class PaymentController extends WalletFormController implements Initializ
public void setPayment(Payment payment) {
if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) {
if(payment.getAddress() != null) {
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
if(dnsPayment != null) {
address.setText(dnsPayment.hrn());
} else if(payment instanceof SilentPayment silentPayment) {
address.setText(silentPayment.getSilentPaymentAddress().getAddress());
} else {
address.setText(payment.getAddress().toString());
}
address.setUserData(payment.getType());
}
if(payment.getLabel() != null && !label.getText().equals(payment.getLabel())) {
@ -564,7 +738,10 @@ public class PaymentController extends WalletFormController implements Initializ
setSendMax(false);
dustAmountProperty.set(false);
consolidationNodeProperty.set(null);
payNymProperty.set(null);
dnsPaymentProperty.set(null);
silentPaymentAddressProperty.set(null);
}
public void setMaxInput(ActionEvent event) {
@ -572,8 +749,7 @@ public class PaymentController extends WalletFormController implements Initializ
if(utxoSelector == null) {
MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector();
sendController.utxoSelectorProperty().set(maxUtxoSelector);
} else if(utxoSelector instanceof PresetUtxoSelector && !isValidAddressAndLabel() && sendController.getPaymentTabs().getTabs().size() == 1) {
PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
} else if(utxoSelector instanceof PresetUtxoSelector presetUtxoSelector && !isValidAddressAndLabel() && sendController.getPaymentTabs().getTabs().size() == 1) {
Payment payment = new Payment(null, null, presetUtxoSelector.getPresetUtxos().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(), true);
setPayment(payment);
return;
@ -625,7 +801,7 @@ public class PaymentController extends WalletFormController implements Initializ
setRecipientValueSats(bitcoinURI.getAmount());
setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), bitcoinURI.getAmount());
}
if(bitcoinURI.getPayjoinUrl() != null) {
if(bitcoinURI.getAddress() != null && bitcoinURI.getPayjoinUrl() != null) {
AppServices.addPayjoinURI(bitcoinURI);
}
sendController.updateTransaction();
@ -676,10 +852,33 @@ public class PaymentController extends WalletFormController implements Initializ
public static Glyph getPayNymGlyph() {
Glyph payNymGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ROBOT);
payNymGlyph.getStyleClass().add("paynym-icon");
payNymGlyph.setFontSize(12);
payNymGlyph.setFontSize(10);
return payNymGlyph;
}
public static Node getBitcoinCharacter() {
try {
URL url;
if(Config.get().getTheme() == Theme.DARK) {
url = AppServices.class.getResource("/image/bitcoin-character-invert.svg");
} else {
url = AppServices.class.getResource("/image/bitcoin-character.svg");
}
if(url != null) {
SVGImage svgImage = SVGLoader.load(url);
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
hBox.getChildren().add(svgImage);
hBox.setPadding(new Insets(0, 2, 0, 4));
return hBox;
}
} catch(Exception e) {
log.error("Could not load bitcoin character");
}
return null;
}
public static Glyph getNfcCardGlyph() {
Glyph nfcCardGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
nfcCardGlyph.getStyleClass().add("nfccard-icon");
@ -723,4 +922,23 @@ public class PaymentController extends WalletFormController implements Initializ
public void openWallets(OpenWalletsEvent event) {
updateOpenWallets(event.getWallets());
}
private static class DnsPaymentService extends Service<Optional<DnsPayment>> {
private final String hrn;
public DnsPaymentService(String hrn) {
this.hrn = hrn;
}
@Override
protected Task<Optional<DnsPayment>> createTask() {
return new Task<>() {
@Override
protected Optional<DnsPayment> call() throws Exception {
DnsPaymentResolver resolver = new DnsPaymentResolver(hrn);
return resolver.resolve();
}
};
}
}
}

View file

@ -6,12 +6,12 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.bip47.SecretPoint;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.control.*;
@ -172,7 +172,7 @@ public class SendController extends WalletFormController implements Initializabl
private final Set<WalletNode> excludedChangeNodes = new HashSet<>();
private final Map<Wallet, Map<Address, WalletNode>> addressNodeMap = new HashMap<>();
private final Map<Address, WalletNode> walletAddresses = new HashMap<>();
private final ChangeListener<String> feeListener = new ChangeListener<>() {
@Override
@ -326,7 +326,7 @@ public class SendController extends WalletFormController implements Initializabl
recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS));
List<BlockSummary> blockSummaries = AppServices.getBlockSummaries().values().stream().sorted().toList();
if(!blockSummaries.isEmpty()) {
recentBlocksView.update(blockSummaries, AppServices.getDefaultFeeRate());
recentBlocksView.update(blockSummaries, AppServices.getNextBlockMedianFeeRate());
}
feeRatesSelectionProperty.addListener((_, oldValue, newValue) -> {
@ -484,18 +484,41 @@ public class SendController extends WalletFormController implements Initializabl
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(fee, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", userFeeSet.get() && insufficientInputsProperty.get()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee Rate", isInsufficientFeeRate())
));
validationSupport.setErrorDecorationEnabled(false);
}
public Tab addPaymentTab() {
public void addPaymentTab() {
if(Config.get().getSuggestSendToMany() == null && openSendToMany()) {
return;
}
Tab tab = getPaymentTab();
paymentTabs.getTabs().add(tab);
paymentTabs.getSelectionModel().select(tab);
return tab;
}
private boolean openSendToMany() {
try {
List<Payment> payments = getPayments();
if(payments.size() == 3) {
ConfirmationAlert confirmationAlert = new ConfirmationAlert("Open Send To Many?", "Open the Tools > Send To Many dialog to add multiple payments?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = confirmationAlert.showAndWait();
if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
Config.get().setSuggestSendToMany(optType.get() == ButtonType.YES);
}
if(optType.isPresent() && optType.get() == ButtonType.YES) {
Platform.runLater(() -> EventManager.get().post(new RequestSendToManyEvent(payments)));
return true;
}
}
} catch(Exception e) {
//ignore
}
return false;
}
public Tab getPaymentTab() {
@ -582,18 +605,25 @@ public class SendController extends WalletFormController implements Initializabl
try {
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
updateOptimizationButtons(payments);
if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
if(!userFeeSet.get() || getFeeValueSats() != null) {
Wallet wallet = getWalletForm().getWallet();
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
double feeRate = getUserFeeRate();
double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
BlockTransaction replacedTransaction = replacedTransactionProperty.get();
walletTransactionService = new WalletTransactionService(addressNodeMap, wallet, getUtxoSelectors(payments), getTxoFilters(),
//Disable RBF for silent payments, as we can't guarantee RBF won't be attempted on another device without knowledge to recompute the address if necessary
boolean allowRbf = (replacedTransaction == null || replacedTransaction.getTransaction().isReplaceByFee())
&& payments.stream().noneMatch(payment -> payment instanceof SilentPayment);
TransactionParameters params = new TransactionParameters(getUtxoSelectors(payments), getTxoFilters(),
payments, opReturnsList, excludedChangeNodes,
feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, replacedTransaction);
feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee,
currentBlockHeight, groupByAddress, includeMempoolOutputs, allowRbf);
walletTransactionService = new WalletTransactionService(wallet, params, replacedTransaction);
walletTransactionService.setOnSucceeded(event -> {
if(!walletTransactionService.isIgnoreResult()) {
walletTransactionProperty.setValue(walletTransactionService.getValue());
@ -628,12 +658,12 @@ public class SendController extends WalletFormController implements Initializabl
walletTransactionService.start();
}
} catch(InvalidAddressException | IllegalStateException e) {
} catch(IllegalStateException e) {
walletTransactionProperty.setValue(null);
}
}
private List<UtxoSelector> getUtxoSelectors(List<Payment> payments) throws InvalidAddressException {
private List<UtxoSelector> getUtxoSelectors(List<Payment> payments) {
if(utxoSelectorProperty.get() != null) {
return List.of(utxoSelectorProperty.get());
}
@ -655,39 +685,14 @@ public class SendController extends WalletFormController implements Initializabl
}
private static class WalletTransactionService extends Service<WalletTransaction> {
private final Map<Wallet, Map<Address, WalletNode>> addressNodeMap;
private final Wallet wallet;
private final List<UtxoSelector> utxoSelectors;
private final List<TxoFilter> txoFilters;
private final List<Payment> payments;
private final List<byte[]> opReturns;
private final Set<WalletNode> excludedChangeNodes;
private final double feeRate;
private final double longTermFeeRate;
private final Long fee;
private final Integer currentBlockHeight;
private final boolean groupByAddress;
private final boolean includeMempoolOutputs;
private final TransactionParameters params;
private final BlockTransaction replacedTransaction;
private boolean ignoreResult;
public WalletTransactionService(Map<Wallet, Map<Address, WalletNode>> addressNodeMap,
Wallet wallet, List<UtxoSelector> utxoSelectors, List<TxoFilter> txoFilters,
List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> excludedChangeNodes,
double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, BlockTransaction replacedTransaction) {
this.addressNodeMap = addressNodeMap;
public WalletTransactionService(Wallet wallet, TransactionParameters params, BlockTransaction replacedTransaction) {
this.wallet = wallet;
this.utxoSelectors = utxoSelectors;
this.txoFilters = txoFilters;
this.payments = payments;
this.opReturns = opReturns;
this.excludedChangeNodes = excludedChangeNodes;
this.feeRate = feeRate;
this.longTermFeeRate = longTermFeeRate;
this.fee = fee;
this.currentBlockHeight = currentBlockHeight;
this.groupByAddress = groupByAddress;
this.includeMempoolOutputs = includeMempoolOutputs;
this.params = params;
this.replacedTransaction = replacedTransaction;
}
@ -698,16 +703,17 @@ public class SendController extends WalletFormController implements Initializabl
try {
return getWalletTransaction();
} catch(InsufficientFundsException e) {
if(e.getTargetValue() != null && replacedTransaction != null && utxoSelectors.size() == 1 && utxoSelectors.get(0) instanceof PresetUtxoSelector presetUtxoSelector) {
if(e.getTargetValue() != null && replacedTransaction != null && wallet.isSafeToAddInputsOrOutputs(replacedTransaction)
&& params.utxoSelectors().size() == 1 && params.utxoSelectors().getFirst() instanceof PresetUtxoSelector presetUtxoSelector) {
//Creating RBF transaction - include additional UTXOs if available to pay desired fee
List<TxoFilter> filters = new ArrayList<>(txoFilters);
List<TxoFilter> filters = new ArrayList<>(params.txoFilters());
filters.add(presetUtxoSelector.asExcludeTxoFilter());
List<OutputGroup> outputGroups = wallet.getGroupedUtxos(filters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
List<OutputGroup> outputGroups = wallet.getGroupedUtxos(filters, params.feeRate(), AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
while(!outputGroups.isEmpty() && presetUtxoSelector.getPresetUtxos().stream().mapToLong(BlockTransactionHashIndex::getValue).sum() < e.getTargetValue()) {
OutputGroup outputGroup = outputGroups.remove(0);
OutputGroup outputGroup = outputGroups.removeFirst();
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
presetUtxoSelector.getPresetUtxos().add(utxo);
}
@ -721,12 +727,12 @@ public class SendController extends WalletFormController implements Initializabl
}
private WalletTransaction getWalletTransaction() throws InsufficientFundsException {
try {
updateMessage("Selecting UTXOs...");
WalletTransaction walletTransaction = wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, excludedChangeNodes,
feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs);
updateMessage("Deriving keys...");
walletTransaction.updateAddressNodeMap(addressNodeMap, walletTransaction.getWallet());
return walletTransaction;
return wallet.createWalletTransaction(params);
} finally {
updateMessage("");
}
}
};
}
@ -854,7 +860,7 @@ public class SendController extends WalletFormController implements Initializabl
* @return the fee rate to use when constructing a transaction
*/
public Double getUserFeeRate() {
return (userFeeSet.get() ? Transaction.DEFAULT_MIN_RELAY_FEE : getFeeRate());
return (userFeeSet.get() ? AppServices.getMinimumRelayFeeRate() : getFeeRate());
}
public Double getFeeRate() {
@ -918,7 +924,6 @@ public class SendController extends WalletFormController implements Initializabl
private void setFeeRatePriority(Double feeRateAmt) {
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
Integer targetBlocks = getTargetBlocks(feeRateAmt);
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
if(minFeeRate > 1.0 && feeRateAmt < minFeeRate) {
@ -939,9 +944,10 @@ public class SendController extends WalletFormController implements Initializabl
}
}
Integer targetBlocks = getTargetBlocks(feeRateAmt);
if(targetBlocks != null) {
if(targetBlocks < FeeRatesSource.BLOCKS_IN_HALF_HOUR) {
Double maxFeeRate = FEE_RATES_RANGE.get(FEE_RATES_RANGE.size() - 1).doubleValue();
Double maxFeeRate = AppServices.getFeeRatesRange().getLast();
Double highestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(0));
if(highestBlocksRate < maxFeeRate && feeRateAmt > (highestBlocksRate + ((maxFeeRate - highestBlocksRate) / 10))) {
feeRatePriority.setText("Overpaid");
@ -1091,7 +1097,7 @@ public class SendController extends WalletFormController implements Initializabl
paymentCodeProperty.set(null);
addressNodeMap.clear();
walletAddresses.clear();
}
public UtxoSelector getUtxoSelector() {
@ -1169,13 +1175,20 @@ public class SendController extends WalletFormController implements Initializabl
WalletTransaction walletTransaction = walletTransactionProperty.get();
Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
nodes.addAll(walletTransaction.getChangeMap().keySet());
Map<Address, WalletNode> addressNodeMap = walletTransaction.getAddressNodeMap();
nodes.addAll(addressNodeMap.values().stream().filter(Objects::nonNull).collect(Collectors.toList()));
nodes.addAll(walletTransaction.getWalletNodePayments().stream().map(WalletNodePayment::getWalletNode).collect(Collectors.toList()));
//All wallet nodes applicable to this transaction are stored so when the subscription status for one is updated, the history for all can be fetched in one atomic update
walletForm.addWalletTransactionNodes(nodes);
}
public WalletNode getWalletNode(Address address) {
if(walletAddresses.isEmpty()) {
walletAddresses.putAll(getWalletForm().getWallet().getWalletAddresses());
}
return walletAddresses.get(address);
}
public void broadcastNotification(ActionEvent event) {
Wallet wallet = getWalletForm().getWallet();
Storage storage = AppServices.get().getOpenWallets().get(wallet);
@ -1205,7 +1218,7 @@ public class SendController extends WalletFormController implements Initializabl
public void broadcastNotification(Wallet decryptedWallet) {
try {
PaymentCode paymentCode = decryptedWallet.getPaymentCode();
PaymentCode paymentCode = decryptedWallet.isMasterWallet() ? decryptedWallet.getPaymentCode() : decryptedWallet.getMasterWallet().getPaymentCode();
PaymentCode externalPaymentCode = paymentCodeProperty.get();
WalletTransaction walletTransaction = walletTransactionProperty.get();
WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue();
@ -1219,11 +1232,14 @@ public class SendController extends WalletFormController implements Initializabl
List<UtxoSelector> utxoSelectors = List.of(new PresetUtxoSelector(walletTransaction.getSelectedUtxos().keySet(), true, false));
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
double feeRate = getUserFeeRate();
Double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(utxoSelectors, getTxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode), excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs);
TransactionParameters params = new TransactionParameters(utxoSelectors, getTxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode),
excludedChangeNodes, feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, true);
WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(params);
PSBT psbt = finalWalletTx.createPSBT();
decryptedWallet.sign(psbt);
decryptedWallet.finalise(psbt);
@ -1412,6 +1428,12 @@ public class SendController extends WalletFormController implements Initializabl
}
feeRange.updateTrackHighlight();
if(event.getNextBlockMedianFeeRate() != null) {
recentBlocksView.updateFeeRate(event.getNextBlockMedianFeeRate());
} else {
recentBlocksView.updateFeeRate(event.getTargetBlockFeeRates());
}
if(updateDefaultFeeRate) {
if(getFeeRate() != null && Long.valueOf((long)getFallbackFeeRate()).equals(getFeeRate().longValue())) {
setDefaultFeeRate();
@ -1434,7 +1456,7 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void blockSummary(BlockSummaryEvent event) {
Platform.runLater(() -> recentBlocksView.update(AppServices.getBlockSummaries().values().stream().sorted().toList(), AppServices.getDefaultFeeRate()));
Platform.runLater(() -> recentBlocksView.update(AppServices.getBlockSummaries().values().stream().sorted().toList(), AppServices.getNextBlockMedianFeeRate()));
}
@Subscribe
@ -1476,7 +1498,7 @@ public class SendController extends WalletFormController implements Initializabl
notificationButton.setVisible(isNotificationTransaction);
notificationButton.setDefaultButton(isNotificationTransaction);
setInputFieldsDisabled(isNotificationTransaction, false);
setInputFieldsDisabled(!event.allowPaymentChanges(), false);
}
}
@ -1600,18 +1622,31 @@ public class SendController extends WalletFormController implements Initializabl
}
}
@Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
recentBlocksView.updateFeeRatesSource(event.getFeeRateSource());
}
@Subscribe
public void connection(ConnectionEvent event) {
if(!Objects.equals(event.getMinimumRelayFeeRate(), event.getPreviousMinimumRelayFeeRate())) {
feeRange.updateFeeRange(event.getMinimumRelayFeeRate(), event.getPreviousMinimumRelayFeeRate());
updateTransaction();
}
}
private class PrivacyAnalysisTooltip extends VBox {
private final List<Label> analysisLabels = new ArrayList<>();
public PrivacyAnalysisTooltip(WalletTransaction walletTransaction) {
List<Payment> payments = walletTransaction.getPayments();
List<Payment> userPayments = payments.stream().filter(payment -> payment.getType() != Payment.Type.FAKE_MIX).collect(Collectors.toList());
Map<Address, WalletNode> walletAddresses = walletTransaction.getAddressNodeMap();
List<WalletNodePayment> walletNodePayments = walletTransaction.getWalletNodePayments();
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
boolean fakeMixPresent = payments.stream().anyMatch(payment -> payment.getType() == Payment.Type.FAKE_MIX);
boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0);
boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
boolean addressReuse = userPayments.stream().anyMatch(payment -> walletAddresses.get(payment.getAddress()) != null && !walletAddresses.get(payment.getAddress()).getTransactionOutputs().isEmpty());
boolean addressReuse = walletNodePayments.stream().anyMatch(walletNodePayment -> !walletNodePayment.getWalletNode().getTransactionOutputs().isEmpty());
boolean payjoinPresent = userPayments.stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
if(optimizationStrategy == OptimizationStrategy.PRIVACY) {

View file

@ -41,8 +41,11 @@ import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.OutputDescriptor.KEY_ORIGIN_PATTERN;
import static com.sparrowwallet.drongo.OutputDescriptor.XPUB_PATTERN;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.AppServices.showWarningDialog;
@ -455,6 +458,26 @@ public class SettingsController extends WalletFormController implements Initiali
AppServices.showWarningDialog("Legacy multisig wallet detected", "Sparrow supports BIP67 compatible multisig wallets only.\n\nThe public keys will be lexicographically sorted, and the output descriptor represented with sortedmulti.");
}
Matcher matcher = XPUB_PATTERN.matcher(text.get());
while(matcher.find()) {
String keyDerivationPath = null;
if(matcher.group(1) != null) {
Matcher keyOriginMatcher = KEY_ORIGIN_PATTERN.matcher(matcher.group(1));
if(keyOriginMatcher.matches()) {
keyDerivationPath = keyOriginMatcher.group(2);
}
}
String extKey = matcher.group(2);
String childDerivationPath = matcher.group(3);
if(ExtendedKey.Header.getHeaders(Network.get()).stream().anyMatch(header -> header.isPrivateKey() && extKey.startsWith(header.name())) &&
(keyDerivationPath != null || (childDerivationPath != null && !(childDerivationPath.equals("/0/*") || childDerivationPath.equals("/1/*") || childDerivationPath.equals("/<0;1>/*"))))) {
AppServices.showWarningDialog("Private extended key detected", "Sparrow will convert the provided private key to a public key for use in a watch only wallet.\n\nTo import a private key, use the Master Private Key option when creating a Software Wallet.");
} else if(childDerivationPath != null && !(childDerivationPath.endsWith("/0/*") || childDerivationPath.endsWith("/1/*") || childDerivationPath.endsWith("/<0;1>/*"))) {
AppServices.showWarningDialog("Non standard child derivation detected", "Sparrow does not support non-BIP32 wallets without standard receive and change chains.\n\nThe provided descriptor will be amended if necessary.");
}
}
setDescriptorText(text.get().replace("\n", ""));
}
}

View file

@ -27,13 +27,14 @@ open module com.sparrowwallet.sparrow {
requires com.google.gson;
requires org.jdbi.v3.core;
requires org.jdbi.v3.sqlobject;
requires io.leangen.geantyref;
requires org.flywaydb.core;
requires com.zaxxer.hikari;
requires com.h2database;
requires com.sparrowwallet.hummingbird;
requires org.fxmisc.flowless;
requires openpnp.capture.java;
requires centerdevice.nsmenufx;
requires nsmenufx;
requires org.jcommander;
requires jul.to.slf4j;
requires net.sourceforge.javacsv;
@ -56,4 +57,5 @@ open module com.sparrowwallet.sparrow {
requires com.sparrowwallet.tern;
requires com.sparrowwallet.lark;
requires com.sun.jna;
requires io.github.doblon8.jzbar;
}

View file

@ -1,76 +0,0 @@
/*------------------------------------------------------------------------
* Config
*
* Copyright 2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
/**
* Decoder configuration options.
*/
public class Config {
/**
* Enable symbology/feature.
*/
public static final int ENABLE = 0;
/**
* Enable check digit when optional.
*/
public static final int ADD_CHECK = 1;
/**
* Return check digit when present.
*/
public static final int EMIT_CHECK = 2;
/**
* Enable full ASCII character set.
*/
public static final int ASCII = 3;
/**
* Minimum data length for valid decode.
*/
public static final int MIN_LEN = 0x20;
/**
* Maximum data length for valid decode.
*/
public static final int MAX_LEN = 0x21;
/**
* Required video consistency frames.
*/
public static final int UNCERTAINTY = 0x40;
/**
* Enable scanner to collect position data.
*/
public static final int POSITION = 0x80;
/**
* Image scanner vertical scan density.
*/
public static final int X_DENSITY = 0x100;
/**
* Image scanner horizontal scan density.
*/
public static final int Y_DENSITY = 0x101;
}

View file

@ -1,197 +0,0 @@
/*------------------------------------------------------------------------
* Image
*
* Copyright 2007-2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
import java.io.Closeable;
/**
* stores image data samples along with associated format and size
* metadata.
*/
public class Image implements Closeable {
static {
init();
}
/**
* C pointer to a zbar_symbol_t.
*/
private long peer;
private Object data;
public Image() {
peer = create();
}
public Image(int width, int height) {
this();
setSize(width, height);
}
public Image(int width, int height, String format) {
this();
setSize(width, height);
setFormat(format);
}
public Image(String format) {
this();
setFormat(format);
}
Image(long peer) {
this.peer = peer;
}
private static native void init();
/**
* Create an associated peer instance.
*/
private native long create();
public void close() {
destroy();
}
/**
* Clean up native data associated with an instance.
*/
public synchronized void destroy() {
if(peer != 0) {
destroy(peer);
peer = 0;
}
}
/**
* Destroy the associated peer instance.
*/
private native void destroy(long peer);
/**
* Image format conversion.
*
* @returns a @em new image with the sample data from the original
* image converted to the requested format fourcc. the original
* image is unaffected.
*/
public Image convert(String format) {
long newpeer = convert(peer, format);
if(newpeer == 0) {
return (null);
}
return (new Image(newpeer));
}
private native long convert(long peer, String format);
/**
* Retrieve the image format fourcc.
*/
public native String getFormat();
/**
* Specify the fourcc image format code for image sample data.
*/
public native void setFormat(String format);
/**
* Retrieve a "sequence" (page/frame) number associated with this
* image.
*/
public native int getSequence();
/**
* Associate a "sequence" (page/frame) number with this image.
*/
public native void setSequence(int seq);
/**
* Retrieve the width of the image.
*/
public native int getWidth();
/**
* Retrieve the height of the image.
*/
public native int getHeight();
/**
* Retrieve the size of the image.
*/
public native int[] getSize();
/**
* Specify the pixel size of the image.
*/
public native void setSize(int[] size);
/**
* Specify the pixel size of the image.
*/
public native void setSize(int width, int height);
/**
* Retrieve the crop region of the image.
*/
public native int[] getCrop();
/**
* Specify the crop region of the image.
*/
public native void setCrop(int[] crop);
/**
* Specify the crop region of the image.
*/
public native void setCrop(int x, int y, int width, int height);
/**
* Retrieve the image sample data.
*/
public native byte[] getData();
/**
* Specify image sample data.
*/
public native void setData(byte[] data);
/**
* Specify image sample data.
*/
public native void setData(int[] data);
/**
* Retrieve the decoded results associated with this image.
*/
public SymbolSet getSymbols() {
return (new SymbolSet(getSymbols(peer)));
}
private native long getSymbols(long peer);
}

View file

@ -1,110 +0,0 @@
/*------------------------------------------------------------------------
* ImageScanner
*
* Copyright 2007-2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
import java.io.Closeable;
/**
* Read barcodes from 2-D images.
*/
public class ImageScanner implements Closeable {
static {
init();
}
/**
* C pointer to a zbar_image_scanner_t.
*/
private long peer;
public ImageScanner() {
peer = create();
}
private static native void init();
/**
* Create an associated peer instance.
*/
private native long create();
public void close() {
destroy();
}
/**
* Clean up native data associated with an instance.
*/
public synchronized void destroy() {
if(peer != 0) {
destroy(peer);
peer = 0;
}
}
/**
* Destroy the associated peer instance.
*/
private native void destroy(long peer);
/**
* Set config for indicated symbology (0 for all) to specified value.
*/
public native void setConfig(int symbology, int config, int value) throws IllegalArgumentException;
/**
* Parse configuration string and apply to image scanner.
*/
public native void parseConfig(String config);
/**
* Enable or disable the inter-image result cache (default disabled).
* Mostly useful for scanning video frames, the cache filters duplicate
* results from consecutive images, while adding some consistency
* checking and hysteresis to the results. Invoking this method also
* clears the cache.
*/
public native void enableCache(boolean enable);
/**
* Retrieve decode results for last scanned image.
*
* @returns the SymbolSet result container
*/
public SymbolSet getResults() {
return (new SymbolSet(getResults(peer)));
}
private native long getResults(long peer);
/**
* Scan for symbols in provided Image.
* The image format must currently be "Y800" or "GRAY".
*
* @returns the number of symbols successfully decoded from the image.
*/
public native int scanImage(Image image);
}

View file

@ -1,44 +0,0 @@
/*------------------------------------------------------------------------
* Modifier
*
* Copyright 2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
/**
* Decoder symbology modifiers.
*/
public class Modifier {
/**
* barcode tagged as GS1 (EAN.UCC) reserved
* (eg, FNC1 before first data character).
* data may be parsed as a sequence of GS1 AIs
*/
public static final int GS1 = 0;
/**
* barcode tagged as AIM reserved
* (eg, FNC1 after first character or digit pair)
*/
public static final int AIM = 1;
}

View file

@ -1,52 +0,0 @@
/*------------------------------------------------------------------------
* Orientation
*
* Copyright 2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
/**
* Decoded symbol coarse orientation.
*/
public class Orientation {
/**
* Unable to determine orientation.
*/
public static final int UNKNOWN = -1;
/**
* Upright, read left to right.
*/
public static final int UP = 0;
/**
* sideways, read top to bottom
*/
public static final int RIGHT = 1;
/**
* upside-down, read right to left
*/
public static final int DOWN = 2;
/**
* sideways, read bottom to top
*/
public static final int LEFT = 3;
}

View file

@ -1,265 +0,0 @@
/*------------------------------------------------------------------------
* Symbol
*
* Copyright 2007-2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
import java.io.Closeable;
/**
* Immutable container for decoded result symbols associated with an image
* or a composite symbol.
*/
public class Symbol implements Closeable {
/**
* No symbol decoded.
*/
public static final int NONE = 0;
/**
* Symbol detected but not decoded.
*/
public static final int PARTIAL = 1;
/**
* EAN-8.
*/
public static final int EAN8 = 8;
/**
* UPC-E.
*/
public static final int UPCE = 9;
/**
* ISBN-10 (from EAN-13).
*/
public static final int ISBN10 = 10;
/**
* UPC-A.
*/
public static final int UPCA = 12;
/**
* EAN-13.
*/
public static final int EAN13 = 13;
/**
* ISBN-13 (from EAN-13).
*/
public static final int ISBN13 = 14;
/**
* Interleaved 2 of 5.
*/
public static final int I25 = 25;
/**
* DataBar (RSS-14).
*/
public static final int DATABAR = 34;
/**
* DataBar Expanded.
*/
public static final int DATABAR_EXP = 35;
/**
* Codabar.
*/
public static final int CODABAR = 38;
/**
* Code 39.
*/
public static final int CODE39 = 39;
/**
* PDF417.
*/
public static final int PDF417 = 57;
/**
* QR Code.
*/
public static final int QRCODE = 64;
/**
* Code 93.
*/
public static final int CODE93 = 93;
/**
* Code 128.
*/
public static final int CODE128 = 128;
static {
init();
}
/**
* C pointer to a zbar_symbol_t.
*/
private long peer;
/**
* Cached attributes.
*/
private int type;
/**
* Symbols are only created by other package methods.
*/
Symbol(long peer) {
this.peer = peer;
}
private static native void init();
public void close() {
destroy();
}
/**
* Clean up native data associated with an instance.
*/
public synchronized void destroy() {
if(peer != 0) {
destroy(peer);
peer = 0;
}
}
/**
* Release the associated peer instance.
*/
private native void destroy(long peer);
/**
* Retrieve type of decoded symbol.
*/
public int getType() {
if(type == 0) {
type = getType(peer);
}
return (type);
}
private native int getType(long peer);
/**
* Retrieve symbology boolean configs settings used during decode.
*/
public native int getConfigMask();
/**
* Retrieve symbology characteristics detected during decode.
*/
public native int getModifierMask();
/**
* Retrieve data decoded from symbol as a String.
*/
public native String getData();
/**
* Retrieve raw data bytes decoded from symbol.
*/
public native byte[] getDataBytes();
/**
* Retrieve a symbol confidence metric. Quality is an unscaled,
* relative quantity: larger values are better than smaller
* values, where "large" and "small" are application dependent.
*/
public native int getQuality();
/**
* Retrieve current cache count. When the cache is enabled for
* the image_scanner this provides inter-frame reliability and
* redundancy information for video streams.
*
* @returns < 0 if symbol is still uncertain
* @returns 0 if symbol is newly verified
* @returns > 0 for duplicate symbols
*/
public native int getCount();
/**
* Retrieve an approximate, axis-aligned bounding box for the
* symbol.
*/
public int[] getBounds() {
int n = getLocationSize(peer);
if(n <= 0) {
return (null);
}
int[] bounds = new int[4];
int xmin = Integer.MAX_VALUE;
int xmax = Integer.MIN_VALUE;
int ymin = Integer.MAX_VALUE;
int ymax = Integer.MIN_VALUE;
for(int i = 0; i < n; i++) {
int x = getLocationX(peer, i);
if(xmin > x) {
xmin = x;
}
if(xmax < x) {
xmax = x;
}
int y = getLocationY(peer, i);
if(ymin > y) {
ymin = y;
}
if(ymax < y) {
ymax = y;
}
}
bounds[0] = xmin;
bounds[1] = ymin;
bounds[2] = xmax - xmin;
bounds[3] = ymax - ymin;
return (bounds);
}
private native int getLocationSize(long peer);
private native int getLocationX(long peer, int idx);
private native int getLocationY(long peer, int idx);
public int[] getLocationPoint(int idx) {
int[] p = new int[2];
p[0] = getLocationX(peer, idx);
p[1] = getLocationY(peer, idx);
return (p);
}
/**
* Retrieve general axis-aligned, orientation of decoded
* symbol.
*/
public native int getOrientation();
/**
* Retrieve components of a composite result.
*/
public SymbolSet getComponents() {
return (new SymbolSet(getComponents(peer)));
}
private native long getComponents(long peer);
native long next();
}

View file

@ -1,75 +0,0 @@
/*------------------------------------------------------------------------
* SymbolIterator
*
* Copyright 2007-2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
/**
* Iterator over a SymbolSet.
*/
public class SymbolIterator implements java.util.Iterator<Symbol> {
/**
* Next symbol to be returned by the iterator.
*/
private Symbol current;
/**
* SymbolIterators are only created by internal interface methods.
*/
SymbolIterator(Symbol first) {
current = first;
}
/**
* Returns true if the iteration has more elements.
*/
public boolean hasNext() {
return (current != null);
}
/**
* Retrieves the next element in the iteration.
*/
public Symbol next() {
if(current == null) {
throw (new java.util.NoSuchElementException("access past end of SymbolIterator"));
}
Symbol result = current;
long sym = current.next();
if(sym != 0) {
current = new Symbol(sym);
} else {
current = null;
}
return (result);
}
/**
* Raises UnsupportedOperationException.
*/
public void remove() {
throw (new UnsupportedOperationException("SymbolIterator is immutable"));
}
}

View file

@ -1,93 +0,0 @@
/*------------------------------------------------------------------------
* SymbolSet
*
* Copyright 2007-2010 (c) Jeff Brown <spadix@users.sourceforge.net>
*
* This file is part of the ZBar Bar Code Reader.
*
* The ZBar Bar Code Reader is free software; you can redistribute it
* and/or modify it under the terms of the GNU Lesser Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* The ZBar Bar Code Reader is distributed in the hope that it will be
* useful, but WITHOUT ANY WARRANTY; without even the implied warranty
* of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser Public License for more details.
*
* You should have received a copy of the GNU Lesser Public License
* along with the ZBar Bar Code Reader; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor,
* Boston, MA 02110-1301 USA
*
* http://sourceforge.net/projects/zbar
*------------------------------------------------------------------------*/
package net.sourceforge.zbar;
import java.io.Closeable;
/**
* Immutable container for decoded result symbols associated with an image
* or a composite symbol.
*/
public class SymbolSet extends java.util.AbstractCollection<Symbol> implements Closeable {
static {
init();
}
/**
* C pointer to a zbar_symbol_set_t.
*/
private long peer;
/**
* SymbolSets are only created by other package methods.
*/
SymbolSet(long peer) {
this.peer = peer;
}
private static native void init();
public void close() {
destroy();
}
/**
* Clean up native data associated with an instance.
*/
public synchronized void destroy() {
if(peer != 0) {
destroy(peer);
peer = 0;
}
}
/**
* Release the associated peer instance.
*/
private native void destroy(long peer);
/**
* Retrieve an iterator over the Symbol elements in this collection.
*/
public java.util.Iterator<Symbol> iterator() {
long sym = firstSymbol(peer);
if(sym == 0) {
return (new SymbolIterator(null));
}
return (new SymbolIterator(new Symbol(sym)));
}
/**
* Retrieve the number of elements in the collection.
*/
public native int size();
/**
* Retrieve C pointer to first symbol in the set.
*/
private native long firstSymbol(long peer);
}

View file

@ -329,6 +329,10 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{
-fx-stroke: #696c77;
}
#blockchainForm #blockStatus {
-fx-text-fill: white;
}
.root .progress-indicator.progress-timer.warn > .determinate-indicator > .indicator {
-fx-background-color: -fx-box-border, radial-gradient(center 50% 50%, radius 50%, #e06c75 70%, derive(-fx-control-inner-background, -9%) 100%);
}

View file

@ -46,7 +46,7 @@
.id, .fixed-width {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
}
.form-separator {

View file

@ -29,7 +29,7 @@
.virtualized-scroll-pane .code-area, .uneditable-codearea {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
-fx-padding: 4;
-fx-fill: -fx-text-inner-color;
}

View file

@ -44,7 +44,7 @@
#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip, #transactionDiagram .transaction-tooltip {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
}
#transactionDiagram .fee-warning-icon {

View file

@ -41,6 +41,8 @@
</columnConstraints>
<rowConstraints>
<RowConstraints />
<RowConstraints />
<RowConstraints vgrow="SOMETIMES" />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2">
<Fieldset text="Transaction" inputGrow="SOMETIMES" wrapWidth="620">
@ -74,9 +76,11 @@
<TabPane side="RIGHT" GridPane.columnIndex="0" GridPane.rowIndex="2" GridPane.columnSpan="2" styleClass="headers-tabs">
<Tab text="Overview" closable="false">
<VBox spacing="8">
<TransactionDiagram fx:id="transactionDiagram" maxWidth="700" final="true"/>
<TransactionDiagramLabel fx:id="transactionDiagramLabel" maxWidth="640" prefWidth="640" />
<VBox spacing="8" alignment="CENTER">
<Region VBox.vgrow="SOMETIMES" />
<TransactionDiagram fx:id="transactionDiagram" final="true"/>
<TransactionDiagramLabel fx:id="transactionDiagramLabel" />
<Region VBox.vgrow="SOMETIMES" />
</VBox>
</Tab>
<Tab text="Detail" closable="false">
@ -176,7 +180,12 @@
<Separator GridPane.columnIndex="0" GridPane.rowIndex="5" GridPane.columnSpan="2" styleClass="form-separator"/>
<DynamicForm fx:id="blockchainForm" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="6">
<GridPane GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="6">
<columnConstraints>
<ColumnConstraints percentWidth="80" />
<ColumnConstraints percentWidth="20" />
</columnConstraints>
<DynamicForm fx:id="blockchainForm" GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset text="Blockchain" inputGrow="SOMETIMES">
<Field text="Status:">
<Label fx:id="blockStatus" contentDisplay="RIGHT" graphicTextGap="5" />
@ -187,8 +196,28 @@
<Field fx:id="blockTimestampField" text="Timestamp:">
<CopyableLabel fx:id="blockTimestamp" />
</Field>
<Field fx:id="signedByField" text="Signed by:">
<CopyableLabel fx:id="signedBy" />
</Field>
</Fieldset>
</DynamicForm>
<Form fx:id="blockchainSpacerForm" GridPane.columnIndex="1" GridPane.rowIndex="0" visible="false">
<Fieldset text="Spacer" inputGrow="SOMETIMES">
<VBox>
<ProgressBar styleClass="signatures-progress-bar" maxWidth="Infinity" minHeight="50" prefHeight="50" progress="0" />
</VBox>
<VBox>
<HBox styleClass="signatures-buttons" spacing="20">
<Button HBox.hgrow="ALWAYS" textAlignment="CENTER" text="Spacer" contentDisplay="TOP" wrapText="true">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="SEARCH" />
</graphic>
</Button>
</HBox>
</VBox>
</Fieldset>
</Form>
</GridPane>
<Form fx:id="signingWalletForm" GridPane.columnIndex="0" GridPane.rowIndex="6">
<Fieldset text="Signatures" inputGrow="SOMETIMES" styleClass="relaxedLabelFieldSet">
@ -236,7 +265,7 @@
<Fieldset text="Signatures" inputGrow="SOMETIMES">
<VBox>
<SignaturesProgressBar fx:id="signaturesProgressBar" />
<ProgressBar fx:id="broadcastProgressBar" maxWidth="Infinity" prefHeight="50" />
<ProgressBar fx:id="broadcastProgressBar" maxWidth="Infinity" minHeight="50" prefHeight="50" />
</VBox>
<VBox>
<HBox fx:id="signButtonBox" styleClass="signatures-buttons" spacing="20">

View file

@ -23,7 +23,7 @@
.chart-legend-item {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
}
.default-color0.chart-pie { -fx-pie-color: #ca1243 }

View file

@ -23,7 +23,7 @@
.chart-legend-item {
-fx-font-size: 13;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
}
.default-color7.chart-pie { -fx-pie-color: #0184bc }

View file

@ -1,7 +1,7 @@
#txhex {
-fx-background-color: -fx-control-inner-background;
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
-fx-padding: 2;
color-0: #ca1243;
color-1: #d75f00;

View file

@ -15,7 +15,7 @@
#fingerprint, #derivation, #xpub {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
}
#type {

View file

@ -21,7 +21,7 @@
<Insets top="10.0" bottom="10.0" />
</padding>
<columnConstraints>
<ColumnConstraints prefWidth="410" />
<ColumnConstraints prefWidth="410" hgrow="SOMETIMES" />
<ColumnConstraints prefWidth="200" />
<ColumnConstraints prefWidth="105" />
</columnConstraints>

View file

@ -26,7 +26,7 @@
<Insets left="25.0" right="25.0" top="25.0" />
</padding>
<columnConstraints>
<ColumnConstraints prefWidth="620" />
<ColumnConstraints prefWidth="620" hgrow="SOMETIMES" />
<ColumnConstraints prefWidth="140" />
</columnConstraints>
<rowConstraints>

View file

@ -71,7 +71,7 @@
#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip, #transactionDiagram .transaction-tooltip {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-font-family: 'Fragment Mono Regular';
}
#transactionDiagram .fee-warning-icon {

View file

@ -36,7 +36,7 @@
<Insets left="25.0" right="25.0" top="25.0" />
</padding>
<columnConstraints>
<ColumnConstraints prefWidth="410" />
<ColumnConstraints prefWidth="410" hgrow="SOMETIMES" />
<ColumnConstraints prefWidth="200" />
<ColumnConstraints prefWidth="140" />
</columnConstraints>
@ -152,9 +152,9 @@
<RecentBlocksView fx:id="recentBlocksView" styleClass="feeRatesChart" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="74" translateY="30" minHeight="135"/>
</AnchorPane>
</GridPane>
<AnchorPane>
<TransactionDiagram fx:id="transactionDiagram" maxWidth="700" AnchorPane.leftAnchor="100" />
</AnchorPane>
<StackPane VBox.vgrow="SOMETIMES">
<TransactionDiagram fx:id="transactionDiagram" />
</StackPane>
</VBox>
</center>
<bottom>

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