Compare commits

...
97 Commits
Author SHA1 Message Date
Craig Raw 880096a193 v1.5.4 2021-12-10 08:07:20 +02:00
Craig Raw 06c0fb8594 add menu item to lock all open wallets in a window 2021-12-10 07:59:44 +02:00
Craig Raw 8e85543c6e fix module related build issue prevent qr scanner from opening 2021-12-10 07:57:56 +02:00
Craig Raw 4fb72fdf89 v1.5.3 2021-12-09 13:07:04 +02:00
Craig Raw cf2616ec2b show progress indicator when retrieving paynym 2021-12-09 12:04:53 +02:00
Craig Raw 238aae5ea9 follow up 2021-12-09 11:51:24 +02:00
Craig Raw a963d10381 change windows and linux installers to use sparrow menu group 2021-12-09 11:50:32 +02:00
Craig Raw eb90d6a31a constrain locktime datetimepicker to show valid values only 2021-12-09 10:28:33 +02:00
Craig Raw b013b5f50f add paging for batched server requests with configurable page size defaulting to 500 ids 2021-12-09 09:29:50 +02:00
Craig Raw 14db333a6f improve passphrase toggle of bitbox02 and trezor t 2021-12-08 14:39:16 +02:00
Craig Raw 6804f713b2 update hummingbird to v1.6.4 (revised crypto-account format) 2021-12-08 10:56:33 +02:00
Craig Raw 90a2c3b89b improve ux when validating transaction locktime datetime field 2021-12-08 10:20:21 +02:00
Craig Raw 3c94664ac3 update utxo tab ui to show utxo balance and count next to smaller utxo chart 2021-12-07 13:29:03 +02:00
Craig Raw 1b61a78e6d add minimum fee rates source of static 1 sat/vb for all block targets 2021-12-07 10:26:33 +02:00
Craig Raw 1defe51fd7 add button to view password field contents 2021-12-06 12:06:51 +02:00
Craig Raw 4cbd778ca1 show wallet display name on mix to button 2021-12-03 14:02:48 +02:00
Craig Raw e59ee47624 remember and select previously selected tab when closing a tab 2021-12-03 12:21:09 +02:00
Craig Raw 8b42399423 allow configuration of a maximum server timeout (maxServerTimeout) in sparrow config 2021-12-03 11:16:01 +02:00
Craig Raw e84f82f47b follow up 2021-12-03 10:45:23 +02:00
Craig Raw e4189711bd improve server connection and wallet loading pulse animation efficiency 2021-12-03 10:34:59 +02:00
Craig Raw 9bca911b0b show only unspent amount in status bar when refreshing postmix wallets 2021-12-02 14:24:09 +02:00
Craig Raw eb498f2bcc improve error handling for paynym.is connection issues 2021-12-02 13:33:30 +02:00
Craig Raw 3d13f596a0 v1.5.3-beta1 2021-12-01 16:07:33 +02:00
Craig Raw 28984427e1 upgrade to h2 2.0.202 2021-12-01 15:37:28 +02:00
Craig Raw ed92acc468 change default min mix to = 3, various minor ux improvements 2021-12-01 15:10:31 +02:00
Craig Raw 3b9c87abc7 improve incorrect password handling on final mix confirm 2021-12-01 14:43:48 +02:00
Craig Raw 00181875c1 followup 2021-12-01 14:14:13 +02:00
Craig Raw 26fb2b97fb add pay to paynym via payjoin 2021-12-01 14:11:16 +02:00
Craig Raw 44194a074c add and integrate paynym dialog 2021-11-30 14:43:23 +02:00
Craig Raw 4edd84f6e2 collaborative mix aesthetic tweaks 2021-11-29 17:08:58 +02:00
Craig Raw 0956c96046 integrate paynyms to collaborative mixing 2021-11-29 15:31:33 +02:00
Craig Raw 3013688447 trigger full wallet refresh when all transaction history has changed on loading 2021-11-26 14:05:56 +02:00
Craig Raw 0302913c3f create two person coinjoin transactions using soroban 2021-11-25 16:15:59 +02:00
Craig Raw 72768362a5 avoid concurrent modification error 2021-11-23 09:21:50 +02:00
Craig Raw 39fa65ea37 restart whirlpool if no utxos mixing, bind debug logging accelerator 2021-11-23 09:17:19 +02:00
Craig Raw 4554c9d0df catch and show hwi enumerate errors 2021-11-17 17:42:50 +02:00
Craig Raw 9b9b295045 force save of temp backup if refreshed wallet transactions are less 2021-11-17 17:13:41 +02:00
Craig Raw ece786131e check if wallet is mixing and restart whirlpool client if necessary 2021-11-17 16:02:11 +02:00
Craig Raw dc65313313 show wallet output descriptors with multipath indexes as per bitcoin core pr #22838 2021-11-13 15:05:45 +02:00
Craig Raw cb41a1ed66 fix import of encrypted json wallet on linux 2021-11-13 10:05:05 +02:00
Craig Raw c566dea232 support tr script expressions in qr crypto-output 2021-11-12 17:18:24 +02:00
Craig Raw 8f04d23b3f add utxo set replacement ui 2021-11-12 16:46:05 +02:00
Craig Raw b8b1039ada show utxo sets in transaction diagram 2021-11-12 15:54:49 +02:00
Craig Raw a7aafa27d0 indicate which accounts are scanned in info dialog 2021-11-12 10:07:53 +02:00
Craig Raw 18a1e82dda fix linux mix to label ellipsis 2021-11-11 18:08:56 +02:00
Craig Raw e26587e807 fix combobox texfield repeat selection 2021-11-11 15:32:37 +02:00
Craig Raw c3778b6419 fix version update hyperlink appearing multiple times 2021-11-10 16:13:23 +02:00
Craig Raw fb85277894 output hwi signing exception to log 2021-11-09 12:35:06 +02:00
Craig Raw 8ae3399d56 fix to avoid scanning with guide box 2021-11-08 11:49:17 +02:00
Craig Raw 7272de90f0 fix mix to wallet display name 2021-11-08 11:11:15 +02:00
Craig Raw 98c1f05ed6 double pass qr with wide and cropped image 2021-11-08 10:30:56 +02:00
Craig Raw 90439501ad improve qr scanning performance by cropping to indicated box 2021-11-07 13:04:20 +02:00
Craig Raw 1bb3833cbe reduce send tab chart min heights 2021-11-05 16:20:35 +02:00
Craig Raw d7d8140050 follow up 2021-11-05 16:01:23 +02:00
Craig Raw 3c77552211 show script type description when importing wallet keystore 2021-11-05 15:57:08 +02:00
Craig Raw 5e7d1d1f69 allow for minimum application height of 708px 2021-11-05 12:59:06 +02:00
Craig Raw 8f92f9ec38 upgrade to javafx 17 2021-11-04 15:19:43 +02:00
Craig Raw af23f063f2 show info dialog when no new accounts are discovered 2021-11-04 14:44:38 +02:00
Craig Raw 13a3ce23e7 make transaction diagram tooltips show indefinitely 2021-11-04 13:21:40 +02:00
Craig Raw d7ff73243c remember recent servers for quick reconfiguration 2021-11-04 11:04:48 +02:00
Craig Raw 25dd0440f6 add show transaction/psbt as qr from file menu 2021-11-03 14:09:49 +02:00
Craig Raw b5301c4556 add public testnet electrum server 2021-11-03 12:27:45 +02:00
Craig Raw a22f69e2c1 improve long fade out animation efficiency 2021-11-02 18:52:37 +02:00
Craig Raw 97f312cb93 v1.5.2 2021-10-29 15:47:48 +02:00
Craig Raw 269fd7f0da fix issue when displaying tx with new wallet 2021-10-29 15:43:52 +02:00
Craig Raw f0a813d031 handle concurrent modifications when saving address nodes 2021-10-29 14:39:51 +02:00
Craig Raw ceb5d85648 various minor fixes 2021-10-29 14:33:47 +02:00
Craig Raw 6b6b23b51a pay to the next fresh address of any open wallet via dropdown on send tab address field 2021-10-29 13:23:27 +02:00
Craig Raw 180e76f0f8 support discovery of singlesig connected hardware wallet accounts 2021-10-29 11:22:34 +02:00
Craig Raw d3b1c51115 increase toggle connecting animation rate 2021-10-28 16:55:24 +02:00
Craig Raw 72cb696451 add bip44 account discovery 2021-10-28 16:44:36 +02:00
Craig Raw 784fa5e1e8 minor whirlpool related fixes 2021-10-28 13:52:47 +02:00
Craig Raw 37aa3c9712 support account renaming, configuration of mix index range 2021-10-27 16:14:35 +02:00
Craig Raw 9520f6d218 upgrade to nightjar 0.2.19 (whirlpool client 0.23.37), minor tx diagram improvements 2021-10-27 12:01:04 +02:00
Craig Raw f4810bb568 show transaction diagram on every transaction headers screen 2021-10-26 17:35:58 +02:00
craigrawandGitHub febd5c33a2 Merge pull request #281 from grettke/master
Add mailmap file for Craig Raw addresses.
2021-10-25 13:47:20 +02:00
Craig Raw 4b39316821 recount mixes if mix data unavailable, correct mix status cell to remove mix progress from non-mixing utxos, show registered inputs total in tooltip 2021-10-25 12:25:18 +02:00
Grant Rettke 69544a2dc8 Add mailmap file for Craig Raw addresses. 2021-10-24 20:03:13 -05:00
craigrawandGitHub 2a0412320a Merge pull request #205 from RequestPrivacy/build
Add note to bitcoinbinary.org in build info
2021-10-22 11:49:09 +02:00
craigrawandGitHub fffd489bcf Merge pull request #278 from alaznem/master
Add other requirements for building to main README
2021-10-22 11:47:56 +02:00
craigrawandGitHub 70a6b259ba Merge pull request #267 from sashafklein/patch-1
Clarify how to run sparrow from CL
2021-10-22 11:43:50 +02:00
Craig Raw 26c177bd00 follow up 2021-10-22 11:19:26 +02:00
Craig Raw 1497b3d3bb add fee rate selection for premix 2021-10-22 11:01:58 +02:00
Alazne MoralesandGitHub 230f3c3db7 Add other requirements for building to main README
The "other requirements" are listed in the [reproducible builds readme](https://github.com/sparrowwallet/sparrow/blob/master/docs/reproducible.md#other-requirements) already. However when trying to quickly build the binaries and follow the main README instructions, the last paragraph about "[m]ore detailed instructions on reproducing the binaries are provided" can be overlooked very easily. Because the paragraph is about the specific topic "reproducible builds" and not building the binaries from source in general.
2021-10-21 20:39:50 +00:00
Craig Raw 813e0f3ab1 retrieve stored index from mixconfig on whirlpool wallet load 2021-10-21 14:38:13 +02:00
Craig Raw 237f97852d add coin selection filter to exclude immature coinbase outputs 2021-10-16 11:10:24 +02:00
Sasha KleinandGitHub 97ef580602 Clarify how to run sparrow from CL
This tiny Readme edit would, I think, have preempted the confusion that prompted #263 . I thought that the general install installed a global command, and didn't think, for some time, to run from within the directory. Dumb, but figured clarifying the doc would be better than just figuring it out silently on my own.
2021-10-14 19:35:37 -05:00
Craig Raw 2debc07375 import and export a wallet as an output descriptor in a text file 2021-10-14 14:27:13 +02:00
Craig Raw 1b3a35fda7 improve ui with whirlpool startup errors 2021-10-14 13:35:17 +02:00
Craig Raw 23fd597ca5 constrain mix from and mix to options to match those supported in the whirlpool client 2021-10-14 11:12:31 +02:00
Craig Raw eb012f638e fix popup window placement on moving active window back to primary screen 2021-10-14 10:23:04 +02:00
Craig Raw 63259a2056 fix lcd text rendering issue on osx 2021-10-14 09:55:26 +02:00
Craig Raw bad209ea5b automatically increase gap limit if required by postmix handler 2021-10-13 15:21:14 +02:00
Craig Raw 776fcb3044 permit mixing from badbank 2021-10-12 12:46:42 +02:00
Craig Raw 63ec856e87 improve transaction file opening io 2021-10-11 11:13:56 +02:00
Craig Raw ada8ca28e8 add tor proxy prompt text 2021-10-11 09:51:25 +02:00
RequestPrivacy 90dea201b9 Add note to bitcoinbinary.org in build info
Actively encourage people to document their build process and provide their work back to the community. Further, push adoption of bitcoinbinary.org as a site for verification and establish sparrow as a well proven/verified wallet.
2021-09-22 15:38:49 +02:00
141 changed files with 6683 additions and 772 deletions
+2
View File
@@ -0,0 +1,2 @@
Craig Raw <craigraw@gmail.com> Craig Raw <craig@quirk.biz>
Craig Raw <craigraw@gmail.com> craigraw <craigraw@gmail.com>
+9 -2
View File
@@ -19,7 +19,12 @@ or for those without SSH credentials:
In order to build, Sparrow requires Java 16 to be installed.
The release binaries are built with [AdoptOpenJdk 16.0.1+9 Hotspot](https://adoptopenjdk.net/archive.html?variant=openjdk16&jvmVariant=hotspot).
These binaries can be built from source using
Other packages may also be necessary to build depending on the platform. On Debian/Ubuntu systems:
`sudo apt install -y rpm fakeroot binutils`
The Sparrow binaries can be built from source using
`./gradlew jpackage`
@@ -29,9 +34,11 @@ When updating to the latest HEAD
The release binaries are reproducible from v1.5.0 onwards (pre codesigning and installer packaging). More detailed [instructions on reproducing the binaries](docs/reproducible.md) are provided.
> A video documention of your built process uploaded to [bitcoinbinary.org](https://bitcoinbinary.org/) is appreciated. Alternatively check the site if you wish to see if someone else already verified the provided binaries.
## Running
If you prefer to run Sparrow directly from source, it can be launched with
If you prefer to run Sparrow directly from source, it can be launched from within the project directory with
`./sparrow`
+37 -14
View File
@@ -1,11 +1,11 @@
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.0.10'
id 'extra-java-module-info'
id 'com.dua3.javafxgradle7plugin' version '0.0.9'
id 'org.beryx.jlink' version '2.24.0'
}
def sparrowVersion = '1.5.1'
def sparrowVersion = '1.5.4'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -29,7 +29,7 @@ tasks.withType(AbstractArchiveTask) {
}
javafx {
version = "16"
version = "17"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -45,13 +45,13 @@ dependencies {
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:1.4.201-SNAPSHOT')
implementation('com.h2database:h2:2.0.202')
implementation('com.zaxxer:HikariCP:4.0.3')
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0')
implementation('org.flywaydb:flyway-core:7.10.5-SNAPSHOT')
implementation('org.flywaydb:flyway-core:7.10.6-SNAPSHOT')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') {
@@ -65,7 +65,8 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.6.2')
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('co.nstant.in:cbor:0.9')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
exclude group: 'com.google.android.tools', module: 'dx'
}
@@ -91,7 +92,11 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.18-SNAPSHOT')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.24')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
testImplementation('junit:junit:4.12')
}
@@ -142,7 +147,7 @@ run {
"--add-opens=java.base/java.io=com.google.gson"]
if(os.macOsX) {
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
}
@@ -191,10 +196,11 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=logback.classic",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.databind",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core"]
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor"]
if(os.macOsX) {
jvmArgs += "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"
jvmArgs += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
}
}
addExtraDependencies("javafx")
@@ -206,12 +212,12 @@ jlink {
imageOptions = []
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/aopp.properties', '--license-file', 'LICENSE']
if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-menu-group', 'Sparrow', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
installerType = "exe"
}
if(os.linux) {
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-rpm-license-type', 'ASL 2.0']
installerOptions += ['--resource-dir', 'src/main/deploy/package/linux/', '--linux-shortcut', '--linux-menu-group', 'Sparrow', '--linux-rpm-license-type', 'ASL 2.0']
imageOptions += ['--icon', 'src/main/deploy/package/linux/Sparrow.png', '--resource-dir', 'src/main/deploy/package/linux/']
}
if(os.macOsX) {
@@ -431,6 +437,11 @@ extraJavaModuleInfo {
requires('javafx.graphics')
requires('javafx.controls')
}
module('rxjavafx-2.2.2.jar', 'io.reactivex.rxjava2fx', '2.2.2') {
exports('io.reactivex.rxjavafx.schedulers')
requires('io.reactivex.rxjava2')
requires('javafx.graphics')
}
module('wellbehavedfx-0.3.3.jar', 'org.fxmisc.wellbehaved', '0.3.3') {
requires('javafx.base')
requires('javafx.graphics')
@@ -441,7 +452,7 @@ extraJavaModuleInfo {
module('kotlin-stdlib-1.5.20.jar', 'kotlin.stdlib', '1.5.20') {
exports('kotlin')
}
module('hummingbird-1.6.2.jar', 'com.sparrowwallet.hummingbird', '1.6.2') {
module('hummingbird-1.6.3.jar', 'com.sparrowwallet.hummingbird', '1.6.3') {
exports('com.sparrowwallet.hummingbird')
exports('com.sparrowwallet.hummingbird.registry')
requires('co.nstant.in.cbor')
@@ -449,7 +460,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module('nightjar-0.2.18-SNAPSHOT.jar', 'com.sparrowwallet.nightjar', '0.2.18-SNAPSHOT') {
module('nightjar-0.2.24.jar', 'com.sparrowwallet.nightjar', '0.2.24') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -459,6 +470,7 @@ extraJavaModuleInfo {
requires('com.fasterxml.jackson.core')
requires('logback.classic')
requires('org.json')
requires('io.reactivex.rxjava2')
exports('com.samourai.http.client')
exports('com.samourai.tor.client')
exports('com.samourai.wallet.api.backend')
@@ -466,6 +478,17 @@ extraJavaModuleInfo {
exports('com.samourai.wallet.client.indexHandler')
exports('com.samourai.wallet.hd')
exports('com.samourai.wallet.util')
exports('com.samourai.wallet.bip47.rpc')
exports('com.samourai.wallet.bip47.rpc.java')
exports('com.samourai.wallet.cahoots')
exports('com.samourai.wallet.cahoots.psbt')
exports('com.samourai.wallet.cahoots.stonewallx2')
exports('com.samourai.soroban.cahoots')
exports('com.samourai.soroban.client')
exports('com.samourai.soroban.client.cahoots')
exports('com.samourai.soroban.client.meeting')
exports('com.samourai.soroban.client.rpc')
exports('com.samourai.wallet.send')
exports('com.samourai.whirlpool.client.event')
exports('com.samourai.whirlpool.client.wallet')
exports('com.samourai.whirlpool.client.wallet.beans')
@@ -59,7 +59,7 @@ abstract public class ExtraModuleInfoTransform implements TransformAction<ExtraM
String originalJarName = originalJar.getName();
//Recreate jackson jars as open, non-synthetic modules
if (isModule(originalJar) && !originalJarName.contains("jackson")) {
if ((isModule(originalJar) && !originalJarName.contains("jackson")) || originalJarName.startsWith("javafx-")) {
outputs.file(originalJar);
} else if (moduleInfo.containsKey(originalJarName)) {
addModuleDescriptor(originalJar, getModuleJar(outputs, originalJar), moduleInfo.get(originalJarName));
+1 -1
View File
@@ -51,7 +51,7 @@ sudo apt install -y rpm fakeroot binutils
The project can cloned for a specific release tag as follows:
```shell
GIT_TAG="1.5.1"
GIT_TAG="1.5.2"
git clone --recursive --branch "${GIT_TAG}" git@github.com:sparrowwallet/sparrow.git
```
+1 -1
Submodule drongo updated: 025af05758...20ec0f95fb
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.1</string>
<string>1.5.4</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -17,6 +17,8 @@ import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.psbt.PSBTSignatureException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -25,6 +27,9 @@ import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.soroban.CounterpartyDialog;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.transaction.TransactionController;
import com.sparrowwallet.sparrow.transaction.TransactionData;
import com.sparrowwallet.sparrow.transaction.TransactionView;
@@ -44,6 +49,7 @@ import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.Side;
import javafx.scene.Node;
@@ -54,10 +60,7 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.Window;
import javafx.stage.*;
import javafx.util.Duration;
import org.controlsfx.control.Notifications;
import org.controlsfx.control.StatusBar;
@@ -69,6 +72,7 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.ParseException;
import java.util.*;
import java.util.stream.Collectors;
@@ -88,12 +92,18 @@ public class AppController implements Initializable {
@FXML
private MenuItem saveTransaction;
@FXML
private MenuItem showTransaction;
@FXML
private Menu savePSBT;
@FXML
private MenuItem savePSBTBinary;
@FXML
private MenuItem showPSBT;
@FXML
private MenuItem exportWallet;
@@ -134,10 +144,6 @@ public class AppController implements Initializable {
private CheckMenuItem showLoadingLog;
private static final BooleanProperty showLoadingLogProperty = new SimpleBooleanProperty();
@FXML
private CheckMenuItem showUtxosChart;
private static final BooleanProperty showUtxosChartProperty = new SimpleBooleanProperty();
@FXML
private CheckMenuItem showTxHex;
private static final BooleanProperty showTxHexProperty = new SimpleBooleanProperty();
@@ -154,6 +160,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem sendToMany;
@FXML
private MenuItem findMixingPartner;
@FXML
private CheckMenuItem preventSleep;
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
@@ -174,6 +183,8 @@ public class AppController implements Initializable {
private Timeline statusTimeline;
private Tab previouslySelectedTab;
private final Set<Wallet> loadingWallets = new LinkedHashSet<>();
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
@@ -218,7 +229,10 @@ public class AppController implements Initializable {
rootStack.getStyleClass().removeAll(DRAG_OVER_CLASS);
});
tabs.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedTab) -> {
tabs.getSelectionModel().selectedItemProperty().addListener((observable, previouslySelectedTab, selectedTab) -> {
if(tabs.getTabs().contains(previouslySelectedTab)) {
this.previouslySelectedTab = previouslySelectedTab;
}
tabs.getTabs().forEach(tab -> ((Label)tab.getGraphic()).getGraphic().setOpacity(TAB_LABEL_GRAPHIC_OPACITY_INACTIVE));
if(selectedTab != null) {
Label tabLabel = (Label)selectedTab.getGraphic();
@@ -237,6 +251,10 @@ public class AppController implements Initializable {
//tabs.setTabDragPolicy(TabPane.TabDragPolicy.REORDER);
tabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
if(c.next() && (c.wasAdded() || c.wasRemoved())) {
if(c.wasRemoved() && previouslySelectedTab != null) {
tabs.getSelectionModel().select(previouslySelectedTab);
}
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
boolean walletRemoved = c.getRemoved().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
if(walletAdded || walletRemoved) {
@@ -298,18 +316,23 @@ public class AppController implements Initializable {
showTxHex.selectedProperty().bindBidirectional(showTxHexProperty);
showLoadingLogProperty.set(Config.get().isShowLoadingLog());
showLoadingLog.selectedProperty().bindBidirectional(showLoadingLogProperty);
showUtxosChartProperty.set(Config.get().isShowUtxosChart());
showUtxosChart.selectedProperty().bindBidirectional(showUtxosChartProperty);
preventSleepProperty.set(Config.get().isPreventSleep());
preventSleep.selectedProperty().bindBidirectional(preventSleepProperty);
saveTransaction.setDisable(true);
showTransaction.visibleProperty().bind(Bindings.and(saveTransaction.visibleProperty(), saveTransaction.disableProperty().not()));
showTransaction.disableProperty().bind(saveTransaction.disableProperty());
savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
showPSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
exportWallet.setDisable(true);
lockWallet.setDisable(true);
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
sendToMany.disableProperty().bind(exportWallet.disableProperty());
findMixingPartner.setDisable(true);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
@@ -395,6 +418,9 @@ public class AppController implements Initializable {
controller.setStage(stage);
controller.initializeView();
setStageIcon(stage);
stage.setOnShowing(event -> {
AppServices.moveToActiveWindowScreen(stage, 600, 460);
});
return stage;
} catch(IOException e) {
@@ -454,10 +480,7 @@ public class AppController implements Initializable {
if(file.exists()) {
try {
byte[] bytes = new byte[(int)file.length()];
FileInputStream stream = new FileInputStream(file);
stream.read(bytes);
stream.close();
byte[] bytes = Files.readAllBytes(file.toPath());
String name = file.getName();
try {
@@ -586,6 +609,23 @@ public class AppController implements Initializable {
}
}
public void showTransaction(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData) tabData;
Transaction transaction = transactionTabData.getTransaction();
try {
UR ur = UR.fromBytes(transaction.bitcoinSerialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur);
qrDisplayDialog.showAndWait();
} catch(Exception e) {
log.error("Error creating UR", e);
}
}
}
public void savePSBTBinary(ActionEvent event) {
savePSBT(false, true);
}
@@ -669,6 +709,18 @@ public class AppController implements Initializable {
}
}
public void showPSBT(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
CryptoPSBT cryptoPSBT = new CryptoPSBT(transactionTabData.getPsbt().serialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR());
qrDisplayDialog.show();
}
}
public List<WalletTabData> getOpenWalletTabData() {
List<WalletTabData> openWalletTabData = new ArrayList<>();
@@ -744,12 +796,6 @@ public class AppController implements Initializable {
EventManager.get().post(new LoadingLogChangedEvent(item.isSelected()));
}
public void showUtxosChart(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowUtxosChart(item.isSelected());
EventManager.get().post(new UtxosChartChangedEvent(item.isSelected()));
}
public void showTxHex(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowTransactionHex(item.isSelected());
@@ -910,7 +956,7 @@ public class AppController implements Initializable {
if(!wallet.isMasterWallet() && wallet.getMasterWallet().getKeystores().size() == copy.getKeystores().size() && wallet.getMasterWallet().getKeystores().get(i).hasSeed()) {
copyKeystore.getSeed().setPassphrase(wallet.getMasterWallet().getKeystores().get(i).getSeed().getPassphrase());
} else {
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(wallet.getFullName(), copyKeystore);
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(wallet.getFullDisplayName(), copyKeystore);
Optional<String> optionalPassphrase = passphraseDialog.showAndWait();
if(optionalPassphrase.isPresent()) {
copyKeystore.getSeed().setPassphrase(optionalPassphrase.get());
@@ -937,6 +983,12 @@ public class AppController implements Initializable {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
whirlpool.setScode(wallet.getMasterMixConfig().getScode());
whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
} else if(Config.get().isUsePayNym() && SorobanServices.canWalletMix(wallet)) {
String walletId = storage.getWalletId(wallet);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setPaymentCode(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
@@ -977,6 +1029,7 @@ public class AppController implements Initializable {
List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(),
new Electrum(),
new SpecterDesktop(),
new Descriptor(),
new CoboVaultSinglesig(), new CoboVaultMultisig(),
new PassportSinglesig(),
new KeystoneSinglesig(), new KeystoneMultisig(),
@@ -1172,6 +1225,70 @@ public class AppController implements Initializable {
}
}
public void findMixingPartner(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
Wallet wallet = selectedWalletForm.getWallet();
Soroban soroban = AppServices.getSorobanServices().getSoroban(selectedWalletForm.getWalletId());
if(soroban.getHdWallet() == null) {
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = selectedWalletForm.getStorage();
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> findMixingPartner(null));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
soroban.setHDWallet(wallet);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
} else {
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
}
}
public void minimizeToTray(ActionEvent event) {
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
}
@@ -1183,6 +1300,17 @@ public class AppController implements Initializable {
}
}
public void lockWallets(ActionEvent event) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData walletTabData) {
if(!walletTabData.getWalletForm().isLocked()) {
EventManager.get().post(new WalletLockEvent(walletTabData.getWalletForm().getMasterWallet()));
}
}
}
}
public void refreshWallet(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
@@ -1280,7 +1408,7 @@ public class AppController implements Initializable {
addWalletSubTab(subTabs, storage, wallet, backupWallet);
Tab masterTab = subTabs.getTabs().stream().filter(tab -> ((WalletTabData)tab.getUserData()).getWallet().isMasterWallet()).findFirst().orElse(subTabs.getTabs().get(0));
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(getAutomaticName(wallet.getMasterWallet()));
masterLabel.setText(wallet.getMasterWallet().getLabel() != null ? wallet.getMasterWallet().getLabel() : wallet.getMasterWallet().getAutomaticName());
Platform.runLater(() -> {
setSubTabsVisible(subTabs, true);
});
@@ -1310,7 +1438,9 @@ public class AppController implements Initializable {
try {
Tab subTab = new Tab();
subTab.setClosable(false);
Label subTabLabel = new Label(wallet.isMasterWallet() ? getAutomaticName(wallet) : wallet.getName());
String label = wallet.getLabel() != null ? wallet.getLabel() : (wallet.isMasterWallet() ? wallet.getAutomaticName() : wallet.getName());
Label subTabLabel = new Label(label);
subTabLabel.setPadding(new Insets(0, 3, 0, 3));
subTabLabel.setGraphic(getSubTabGlyph(wallet));
subTabLabel.setContentDisplay(ContentDisplay.TOP);
subTabLabel.setAlignment(Pos.TOP_CENTER);
@@ -1328,6 +1458,9 @@ public class AppController implements Initializable {
TabData tabData = new WalletTabData(TabData.TabType.WALLET, walletForm);
subTab.setUserData(tabData);
if(!wallet.isWhirlpoolChildWallet()) {
subTab.setContextMenu(getSubTabContextMenu(subTab));
}
subTabs.getTabs().add(subTab);
subTabs.getTabs().sort((o1, o2) -> {
@@ -1360,11 +1493,6 @@ public class AppController implements Initializable {
return tabGlyph;
}
private String getAutomaticName(Wallet wallet) {
int account = wallet.getAccountIndex();
return (account < 0 || account > 9) ? wallet.getName() : (!wallet.isWhirlpoolMasterWallet() || account > 1 ? "Account #" + account : "Deposit");
}
public WalletForm getSelectedWalletForm() {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
@@ -1378,24 +1506,6 @@ public class AppController implements Initializable {
return null;
}
public void openExamples(ActionEvent event) {
try {
addTransactionTab("p2pkh", null, "01000000019c2e0f24a03e72002a96acedb12a632e72b6b74c05dc3ceab1fe78237f886c48010000006a47304402203da9d487be5302a6d69e02a861acff1da472885e43d7528ed9b1b537a8e2cac9022002d1bca03a1e9715a99971bafe3b1852b7a4f0168281cbd27a220380a01b3307012102c9950c622494c2e9ff5a003e33b690fe4832477d32c2d256c67eab8bf613b34effffffff02b6f50500000000001976a914bdf63990d6dc33d705b756e13dd135466c06b3b588ac845e0201000000001976a9145fb0e9755a3424efd2ba0587d20b1e98ee29814a88ac06241559");
addTransactionTab("p2sh", null, "0100000003a5ee1a0fd80dfbc3142df136ab56e082b799c13aa977c048bdf8f61bd158652c000000006b48304502203b0160de302cded63589a88214fe499a25aa1d86a2ea09129945cd632476a12c022100c77727daf0718307e184d55df620510cf96d4b5814ae3258519c0482c1ca82fa0121024f4102c1f1cf662bf99f2b034eb03edd4e6c96793cb9445ff519aab580649120ffffffff0fce901eb7b7551ba5f414735ff93b83a2a57403df11059ec88245fba2aaf1a0000000006a47304402204089adb8a1de1a9e22aa43b94d54f1e54dc9bea745d57df1a633e03dd9ede3c2022037d1e53e911ed7212186028f2e085f70524930e22eb6184af090ba4ab779a5b90121030644cb394bf381dbec91680bdf1be1986ad93cfb35603697353199fb285a119effffffff0fce901eb7b7551ba5f414735ff93b83a2a57403df11059ec88245fba2aaf1a0010000009300493046022100a07b2821f96658c938fa9c68950af0e69f3b2ce5f8258b3a6ad254d4bc73e11e022100e82fab8df3f7e7a28e91b3609f91e8ebf663af3a4dc2fd2abd954301a5da67e701475121022afc20bf379bc96a2f4e9e63ffceb8652b2b6a097f63fbee6ecec2a49a48010e2103a767c7221e9f15f870f1ad9311f5ab937d79fcaeee15bb2c722bca515581b4c052aeffffffff02a3b81b00000000001976a914ea00917f128f569cbdf79da5efcd9001671ab52c88ac80969800000000001976a9143dec0ead289be1afa8da127a7dbdd425a05e25f688ac00000000");
addTransactionTab("p2sh-p2wpkh", null, "01000000000101db6b1b20aa0fd7b23880be2ecbd4a98130974cf4748fb66092ac4d3ceb1a5477010000001716001479091972186c449eb1ded22b78e40d009bdf0089feffffff02b8b4eb0b000000001976a914a457b684d7f0d539a46a45bbc043f35b59d0d96388ac0008af2f000000001976a914fd270b1ee6abcaea97fea7ad0402e8bd8ad6d77c88ac02473044022047ac8e878352d3ebbde1c94ce3a10d057c24175747116f8288e5d794d12d482f0220217f36a485cae903c713331d877c1f64677e3622ad4010726870540656fe9dcb012103ad1d8e89212f0b92c74d23bb710c00662ad1470198ac48c43f7d6f93a2a2687392040000");
addTransactionTab("p2sh-p2wsh", null, "01000000000101708256c5896fb3f00ef37601f8e30c5b460dbcd1fca1cd7199f9b56fc4ecd5400000000023220020615ae01ed1bc1ffaad54da31d7805d0bb55b52dfd3941114330368c1bbf69b4cffffffff01603edb0300000000160014bbef244bcad13cffb68b5cef3017c7423675552204004730440220010d2854b86b90b7c33661ca25f9d9f15c24b88c5c4992630f77ff004b998fb802204106fc3ec8481fa98e07b7e78809ac91b6ccaf60bf4d3f729c5a75899bb664a501473044022046d66321c6766abcb1366a793f9bfd0e11e0b080354f18188588961ea76c5ad002207262381a0661d66f5c39825202524c45f29d500c6476176cd910b1691176858701695221026ccfb8061f235cc110697c0bfb3afb99d82c886672f6b9b5393b25a434c0cbf32103befa190c0c22e2f53720b1be9476dcf11917da4665c44c9c71c3a2d28a933c352102be46dc245f58085743b1cc37c82f0d63a960efa43b5336534275fc469b49f4ac53ae00000000");
addTransactionTab("p2wpkh", null, "01000000000101109d2e41430bfdec7e6dfb02bf78b5827eeb717ef25210ff3203b0db8c76c9260000000000ffffffff01a032eb0500000000160014bbef244bcad13cffb68b5cef3017c742367555220247304402202f7cac3494e521018ae0be4ca18517639ef7c00658d42a9f938b2b344c8454e2022039a54218832fad5d14b331329d9042c51ee6be287e95e49ee5b96fda1f5ce13f0121026ccfb8061f235cc110697c0bfb3afb99d82c886672f6b9b5393b25a434c0cbf300000000");
addTransactionTab("p2wsh", null, "0100000000010193a2db37b841b2a46f4e9bb63fe9c1012da3ab7fe30b9f9c974242778b5af8980000000000ffffffff01806fb307000000001976a914bbef244bcad13cffb68b5cef3017c7423675552288ac040047304402203cdcaf02a44e37e409646e8a506724e9e1394b890cb52429ea65bac4cc2403f1022024b934297bcd0c21f22cee0e48751c8b184cc3a0d704cae2684e14858550af7d01483045022100feb4e1530c13e72226dc912dcd257df90d81ae22dbddb5a3c2f6d86f81d47c8e022069889ddb76388fa7948aaa018b2480ac36132009bb9cfade82b651e88b4b137a01695221026ccfb8061f235cc110697c0bfb3afb99d82c886672f6b9b5393b25a434c0cbf32103befa190c0c22e2f53720b1be9476dcf11917da4665c44c9c71c3a2d28a933c352102be46dc245f58085743b1cc37c82f0d63a960efa43b5336534275fc469b49f4ac53ae00000000");
//addTransactionTab("test1", null, "02000000000102ba4dc5a4a14bfaa941b7d115b379b5e15f960635cf694c178b9116763cbd63b11600000017160014fc164cbcac023f5eacfcead2d17d8768c41949affeffffff074d44d2856beb68ba52e8832da60a1682768c2421c2d9a8109ef4e66babd1fd1e000000171600148c3098be6b430859115f5ee99c84c368afecd0481500400002305310000000000017a914ffaf369c2212b178c7a2c21c9ccdd5d126e74c4187327f0300000000001976a914a7cda2e06b102a143ab606937a01d152e300cd3e88ac02473044022006da0ca227f765179219e08a33026b94e7cacff77f87b8cd8eb1b46d6dda11d6022064faa7912924fd23406b6ed3328f1bbbc3760dc51109a49c1b38bf57029d304f012103c6a2fcd030270427d4abe1041c8af929a9e2dbab07b243673453847ab842ee1f024730440220786316a16095105a0af28dccac5cf80f449dea2ea810a9559a89ecb989c2cb3d02205cbd9913d1217ffec144ae4f2bd895f16d778c2ec49ae9c929fdc8bcc2a2b1db0121024d4985241609d072a59be6418d700e87688f6c4d99a51ad68e66078211f076ee38820900");
//addTransactionTab("3of3-1s.psbt", null, "70736274ff0100550200000001294c4871c059bb76be81e94b78059ee2e0c9b1b47f38edb6b4e75916062394930000000000feffffff01f82a0000000000001976a914e65b294f890792f2c2725d488567018d660f0cf488ac701c09004f0102aa7ed3044b1635bb800000021bf4bfc48934b7966b39bdebb689525d9b8bfed5c8b16e8c58f9afe4641d6d5f03800b5dbec0355c9f0b5e8227bc903e9d0ff1fe6ced0dcfb6d416541c7412c4331406b57041300000800000008000000080020000804f0102aa7ed3042cd31dee80000002d544b2364010378f8c6cec85f6b7ed83a8203dcdbedb97e2625f431f897b837e0363428de8fcfbfe373c0d9e1e0cc8163d886764bafe71c5822eaa232981356589145f63394f300000800000008000000080020000804f0102aa7ed3049ec7d9f580000002793e04aff18b4e40ebc48bcdc6232c54c69cf7265a38fbd85b35705e34d2d42f03368e79aa2b2b7f736d156905a7a45891df07baa2d0b7f127a537908cb82deed514130a48af300000800000008000000080020000800001012b983a000000000000220020f64748dad1cbad107761aaed5c59f25aba006498d260b440e0a091691350c9aa010569532102f26969eb8d1da34d17d33ff99e2f020cc33b3d11d9798ec14f46b82bc455d3262103171d9b824205cd5db6e9353676a292ca954b24d8310a36fc983469ba3fb507a221037f3794f3be4c4acc086ac84d6902c025713eabf8890f20f44acf0b34e3c0f0f753ae220602f26969eb8d1da34d17d33ff99e2f020cc33b3d11d9798ec14f46b82bc455d3261c130a48af300000800000008000000080020000800000000000000000220603171d9b824205cd5db6e9353676a292ca954b24d8310a36fc983469ba3fb507a21c5f63394f300000800000008000000080020000800000000000000000220203171d9b824205cd5db6e9353676a292ca954b24d8310a36fc983469ba3fb507a24830450221008d27cc4b03bc543726e73b69e7980e7364d6f33f979a5cd9b92fb3d050666bd002204fc81fc9c67baf7c3b77041ed316714a9c117a5bdbb020e8c771ea3bdc342434012206037f3794f3be4c4acc086ac84d6902c025713eabf8890f20f44acf0b34e3c0f0f71c06b570413000008000000080000000800200008000000000000000000000");
//addTransactionTab("signer.psbt", null, "70736274ff01009a020000000258e87a21b56daf0c23be8e7070456c336f7cbaa5c8757924f545887bb2abdd750000000000ffffffff838d0427d0ec650a68aa46bb0b098aea4422c071b2ca78352a077959d07cea1d0100000000ffffffff0270aaf00800000000160014d85c2b71d0060b09c9886aeb815e50991dda124d00e1f5050000000016001400aea9a2e5f0f876a588df5546e8742d1d87008f00000000000100bb0200000001aad73931018bd25f84ae400b68848be09db706eac2ac18298babee71ab656f8b0000000048473044022058f6fc7c6a33e1b31548d481c826c015bd30135aad42cd67790dab66d2ad243b02204a1ced2604c6735b6393e5b41691dd78b00f0c5942fb9f751856faa938157dba01feffffff0280f0fa020000000017a9140fb9463421696b82c833af241c78c17ddbde493487d0f20a270100000017a91429ca74f8a08f81999428185c97b5d852e4063f618765000000220202dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d7483045022100f61038b308dc1da865a34852746f015772934208c6d24454393cd99bdf2217770220056e675a675a6d0a02b85b14e5e29074d8a25a9b5760bea2816f661910a006ea01010304010000000104475221029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f2102dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d752ae2206029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f10d90c6a4f000000800000008000000080220602dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d710d90c6a4f0000008000000080010000800001012000c2eb0b0000000017a914b7f5faf40e3d40a5a459b1db3535f2b72fa921e8872202023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e73473044022065f45ba5998b59a27ffe1a7bed016af1f1f90d54b3aa8f7450aa5f56a25103bd02207f724703ad1edb96680b284b56d4ffcb88f7fb759eabbe08aa30f29b851383d2010103040100000001042200208c2353173743b595dfb4a07b72ba8e42e3797da74e87fe7d9d7497e3b2028903010547522103089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc21023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7352ae2206023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7310d90c6a4f000000800000008003000080220603089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc10d90c6a4f00000080000000800200008000220203a9a4c37f5996d3aa25dbac6b570af0650394492942460b354753ed9eeca5877110d90c6a4f000000800000008004000080002202027f6399757d2eff55a136ad02c684b1838b6556e5f1b6b34282a94b6b5005109610d90c6a4f00000080000000800500008000");
//addTransactionTab("combiner.psbt", null, "70736274ff01009a020000000258e87a21b56daf0c23be8e7070456c336f7cbaa5c8757924f545887bb2abdd750000000000ffffffff838d0427d0ec650a68aa46bb0b098aea4422c071b2ca78352a077959d07cea1d0100000000ffffffff0270aaf00800000000160014d85c2b71d0060b09c9886aeb815e50991dda124d00e1f5050000000016001400aea9a2e5f0f876a588df5546e8742d1d87008f00000000000100bb0200000001aad73931018bd25f84ae400b68848be09db706eac2ac18298babee71ab656f8b0000000048473044022058f6fc7c6a33e1b31548d481c826c015bd30135aad42cd67790dab66d2ad243b02204a1ced2604c6735b6393e5b41691dd78b00f0c5942fb9f751856faa938157dba01feffffff0280f0fa020000000017a9140fb9463421696b82c833af241c78c17ddbde493487d0f20a270100000017a91429ca74f8a08f81999428185c97b5d852e4063f6187650000002202029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f473044022074018ad4180097b873323c0015720b3684cc8123891048e7dbcd9b55ad679c99022073d369b740e3eb53dcefa33823c8070514ca55a7dd9544f157c167913261118c01220202dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d7483045022100f61038b308dc1da865a34852746f015772934208c6d24454393cd99bdf2217770220056e675a675a6d0a02b85b14e5e29074d8a25a9b5760bea2816f661910a006ea01010304010000000104475221029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f2102dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d752ae2206029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f10d90c6a4f000000800000008000000080220602dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d710d90c6a4f0000008000000080010000800001012000c2eb0b0000000017a914b7f5faf40e3d40a5a459b1db3535f2b72fa921e887220203089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc473044022062eb7a556107a7c73f45ac4ab5a1dddf6f7075fb1275969a7f383efff784bcb202200c05dbb7470dbf2f08557dd356c7325c1ed30913e996cd3840945db12228da5f012202023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e73473044022065f45ba5998b59a27ffe1a7bed016af1f1f90d54b3aa8f7450aa5f56a25103bd02207f724703ad1edb96680b284b56d4ffcb88f7fb759eabbe08aa30f29b851383d2010103040100000001042200208c2353173743b595dfb4a07b72ba8e42e3797da74e87fe7d9d7497e3b2028903010547522103089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc21023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7352ae2206023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7310d90c6a4f000000800000008003000080220603089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc10d90c6a4f00000080000000800200008000220203a9a4c37f5996d3aa25dbac6b570af0650394492942460b354753ed9eeca5877110d90c6a4f000000800000008004000080002202027f6399757d2eff55a136ad02c684b1838b6556e5f1b6b34282a94b6b5005109610d90c6a4f00000080000000800500008000");
addTransactionTab("finalizer.psbt", null, "70736274ff01009a020000000258e87a21b56daf0c23be8e7070456c336f7cbaa5c8757924f545887bb2abdd750000000000ffffffff838d0427d0ec650a68aa46bb0b098aea4422c071b2ca78352a077959d07cea1d0100000000ffffffff0270aaf00800000000160014d85c2b71d0060b09c9886aeb815e50991dda124d00e1f5050000000016001400aea9a2e5f0f876a588df5546e8742d1d87008f00000000000100bb0200000001aad73931018bd25f84ae400b68848be09db706eac2ac18298babee71ab656f8b0000000048473044022058f6fc7c6a33e1b31548d481c826c015bd30135aad42cd67790dab66d2ad243b02204a1ced2604c6735b6393e5b41691dd78b00f0c5942fb9f751856faa938157dba01feffffff0280f0fa020000000017a9140fb9463421696b82c833af241c78c17ddbde493487d0f20a270100000017a91429ca74f8a08f81999428185c97b5d852e4063f6187650000000107da00473044022074018ad4180097b873323c0015720b3684cc8123891048e7dbcd9b55ad679c99022073d369b740e3eb53dcefa33823c8070514ca55a7dd9544f157c167913261118c01483045022100f61038b308dc1da865a34852746f015772934208c6d24454393cd99bdf2217770220056e675a675a6d0a02b85b14e5e29074d8a25a9b5760bea2816f661910a006ea01475221029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f2102dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d752ae0001012000c2eb0b0000000017a914b7f5faf40e3d40a5a459b1db3535f2b72fa921e8870107232200208c2353173743b595dfb4a07b72ba8e42e3797da74e87fe7d9d7497e3b20289030108da0400473044022062eb7a556107a7c73f45ac4ab5a1dddf6f7075fb1275969a7f383efff784bcb202200c05dbb7470dbf2f08557dd356c7325c1ed30913e996cd3840945db12228da5f01473044022065f45ba5998b59a27ffe1a7bed016af1f1f90d54b3aa8f7450aa5f56a25103bd02207f724703ad1edb96680b284b56d4ffcb88f7fb759eabbe08aa30f29b851383d20147522103089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc21023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7352ae00220203a9a4c37f5996d3aa25dbac6b570af0650394492942460b354753ed9eeca5877110d90c6a4f000000800000008004000080002202027f6399757d2eff55a136ad02c684b1838b6556e5f1b6b34282a94b6b5005109610d90c6a4f00000080000000800500008000");
} catch(Exception e) {
log.error("Error opening examples", e);
}
}
private void addTransactionTab(String name, File file, String string) throws ParseException, PSBTParseException, TransactionParseException {
if(Utils.isBase64(string) && !Utils.isHex(string)) {
addTransactionTab(name, file, Base64.getDecoder().decode(string));
@@ -1577,6 +1687,28 @@ public class AppController implements Initializable {
return contextMenu;
}
private ContextMenu getSubTabContextMenu(Tab subTab) {
ContextMenu contextMenu = new ContextMenu();
MenuItem rename = new MenuItem("Rename Account");
rename.setOnAction(event -> {
Label subTabLabel = (Label)subTab.getGraphic();
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(subTabLabel.getText());
Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) {
String label = optLabel.get();
subTabLabel.setText(label);
WalletTabData walletTabData = (WalletTabData)subTab.getUserData();
Wallet wallet = walletTabData.getWallet();
wallet.setLabel(label);
EventManager.get().post(new WalletLabelChangedEvent(wallet));
}
});
contextMenu.getItems().add(rename);
return contextMenu;
}
public void setServerType(ServerType serverType) {
if(serverType == ServerType.PUBLIC_ELECTRUM_SERVER && !serverToggle.getStyleClass().contains("public-server")) {
serverToggle.getStyleClass().add("public-server");
@@ -1605,21 +1737,17 @@ public class AppController implements Initializable {
private void serverToggleStartAnimation() {
Node thumbArea = serverToggle.lookup(".thumb-area");
if(thumbArea != null) {
FadeTransition fadeTransition = new FadeTransition(Duration.millis(1000), thumbArea);
fadeTransition.setFromValue(1.0);
fadeTransition.setToValue(0.4);
fadeTransition.setAutoReverse(true);
fadeTransition.setCycleCount(Animation.INDEFINITE);
fadeTransition.play();
serverToggle.setUserData(fadeTransition);
Timeline timeline = AnimationUtil.getPulse(thumbArea, Duration.millis(600), 1.0, 0.4, 8);
timeline.play();
serverToggle.setUserData(new AnimationUtil.AnimatedNode(thumbArea, timeline));
}
}
private void serverToggleStopAnimation() {
if(serverToggle.getUserData() != null) {
FadeTransition fadeTransition = (FadeTransition)serverToggle.getUserData();
fadeTransition.stop();
fadeTransition.getNode().setOpacity(1.0);
AnimationUtil.AnimatedNode animatedNode = (AnimationUtil.AnimatedNode)serverToggle.getUserData();
animatedNode.timeline().stop();
animatedNode.node().setOpacity(1.0);
serverToggle.setUserData(null);
}
}
@@ -1635,13 +1763,9 @@ public class AppController implements Initializable {
private void tabLabelStartAnimation(Tab tab) {
Label tabLabel = (Label) tab.getGraphic();
if(tabLabel.getUserData() == null) {
FadeTransition fadeTransition = new FadeTransition(Duration.millis(1000), tabLabel.getGraphic());
fadeTransition.setFromValue(tabLabel.getGraphic().getOpacity());
fadeTransition.setToValue(0.1);
fadeTransition.setAutoReverse(true);
fadeTransition.setCycleCount(Animation.INDEFINITE);
fadeTransition.play();
tabLabel.setUserData(fadeTransition);
Timeline timeline = AnimationUtil.getPulse(tabLabel.getGraphic(), Duration.millis(1000), tabLabel.getGraphic().getOpacity(), 0.1, 8);
timeline.play();
tabLabel.setUserData(timeline);
}
}
@@ -1671,8 +1795,8 @@ public class AppController implements Initializable {
private void tabLabelStopAnimation(Tab tab) {
Label tabLabel = (Label) tab.getGraphic();
if(tabLabel.getUserData() != null) {
FadeTransition fadeTransition = (FadeTransition)tabLabel.getUserData();
fadeTransition.stop();
Animation animation = (Animation)tabLabel.getUserData();
animation.stop();
tabLabel.setUserData(null);
tabLabel.getGraphic().setOpacity(tab.isSelected() ? TAB_LABEL_GRAPHIC_OPACITY_ACTIVE : TAB_LABEL_GRAPHIC_OPACITY_INACTIVE);
}
@@ -1744,8 +1868,8 @@ public class AppController implements Initializable {
lockWallet.setDisable(true);
exportWallet.setDisable(true);
showLoadingLog.setDisable(true);
showUtxosChart.setDisable(true);
showTxHex.setDisable(false);
findMixingPartner.setDisable(true);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
WalletTabData walletTabData = walletTabEvent.getWalletTabData();
@@ -1754,8 +1878,8 @@ public class AppController implements Initializable {
lockWallet.setDisable(walletTabData.getWalletForm().lockedProperty().get());
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid());
showLoadingLog.setDisable(false);
showUtxosChart.setDisable(false);
showTxHex.setDisable(true);
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
}
}
}
@@ -1780,6 +1904,7 @@ public class AppController implements Initializable {
if(selectedWalletForm != null) {
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
}
}
}
@@ -1802,7 +1927,7 @@ public class AppController implements Initializable {
}
List<BlockTransaction> blockTransactions = new ArrayList<>(event.getBlockTransactions());
List<BlockTransaction> whirlpoolTransactions = event.getWhirlpoolMixTransactions();
List<BlockTransaction> whirlpoolTransactions = event.getUnspentConfirmingWhirlpoolMixTransactions();
blockTransactions.removeAll(whirlpoolTransactions);
if(!whirlpoolTransactions.isEmpty()) {
@@ -1909,8 +2034,15 @@ public class AppController implements Initializable {
AppServices.get().getApplication().getHostServices().showDocument("https://www.sparrowwallet.com/download");
});
if(statusBar.getRightItems().size() > 0 && statusBar.getRightItems().get(0) instanceof Hyperlink) {
statusBar.getRightItems().remove(0);
Hyperlink existingUpdateLabel = null;
for(Node node : statusBar.getRightItems()) {
if(node instanceof Hyperlink) {
existingUpdateLabel = (Hyperlink)node;
}
}
if(existingUpdateLabel != null) {
statusBar.getRightItems().remove(existingUpdateLabel);
}
statusBar.getRightItems().add(0, versionUpdateLabel);
@@ -2031,7 +2163,7 @@ public class AppController implements Initializable {
@Subscribe
public void walletHistoryStarted(WalletHistoryStartedEvent event) {
if(AppServices.isConnected() && getOpenWallets().containsKey(event.getWallet())) {
if(event.getWalletNode() == null && event.getWallet().getTransactions().isEmpty()) {
if(event.getWalletNodes() == null && event.getWallet().getTransactions().isEmpty()) {
statusUpdated(new StatusEvent(LOADING_TRANSACTIONS_MESSAGE, 120));
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(-1);
@@ -16,6 +16,7 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
@@ -77,6 +78,8 @@ public class AppServices {
private final WhirlpoolServices whirlpoolServices = new WhirlpoolServices();
private final SorobanServices sorobanServices = new SorobanServices();
private final MainApp application;
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
@@ -151,6 +154,7 @@ public class AppServices {
this.application = application;
EventManager.get().register(this);
EventManager.get().register(whirlpoolServices);
EventManager.get().register(sorobanServices);
}
public void start() {
@@ -471,6 +475,10 @@ public class AppServices {
return get().whirlpoolServices;
}
public static SorobanServices getSorobanServices() {
return get().sorobanServices;
}
public static AppController newAppWindow(Stage stage) {
try {
FXMLLoader appLoader = new FXMLLoader(AppServices.class.getResource("app.fxml"));
@@ -482,7 +490,7 @@ public class AppServices {
stage.setTitle("Sparrow");
stage.setMinWidth(650);
stage.setMinHeight(730);
stage.setMinHeight(708);
stage.setScene(scene);
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow-large.png")));
@@ -622,7 +630,7 @@ public class AppServices {
}
public static Optional<ButtonType> showErrorDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.ERROR, buttons);
return showAlertDialog(title, content == null ? "See log file (Help menu)" : content, Alert.AlertType.ERROR, buttons);
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
@@ -695,7 +703,9 @@ public class AppServices {
public static void moveToWindowScreen(Window currentWindow, Window newWindow, double newWindowWidth, double newWindowHeight) {
Screen currentScreen = Screen.getScreens().stream().filter(screen -> screen.getVisualBounds().contains(currentWindow.getX(), currentWindow.getY())).findFirst().orElse(null);
if(currentScreen != null && !Screen.getPrimary().getVisualBounds().contains(currentWindow.getX(), currentWindow.getY()) && !currentScreen.getVisualBounds().contains(newWindow.getX(), newWindow.getY())) {
if(currentScreen != null
&& ((!Double.isNaN(newWindow.getX()) && !Double.isNaN(newWindow.getY())) || !Screen.getPrimary().getVisualBounds().contains(currentWindow.getX(), currentWindow.getY()))
&& !currentScreen.getVisualBounds().contains(newWindow.getX(), newWindow.getY())) {
double x = currentWindow.getX() + (currentWindow.getWidth() / 2) - (newWindowWidth / 2);
double y = currentWindow.getY() + (currentWindow.getHeight() / 2.2) - (newWindowHeight / 2);
newWindow.setX(x);
@@ -844,6 +854,7 @@ public class AppServices {
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
}
@Subscribe
@@ -8,7 +8,6 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Bwt;
import com.sparrowwallet.sparrow.net.PublicElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
@@ -31,7 +30,7 @@ import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.5.1";
public static final String APP_VERSION = "1.5.4";
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";
@@ -77,7 +76,8 @@ public class MainApp extends Application {
createNewWallet = optNewWallet.isPresent() && optNewWallet.get();
} else if(Network.get() == Network.MAINNET) {
Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
Config.get().setPublicElectrumServer(PublicElectrumServer.values()[new Random().nextInt(PublicElectrumServer.values().length)].getUrl());
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
Config.get().setPublicElectrumServer(servers.get(new Random().nextInt(servers.size())).getUrl());
}
}
}
@@ -1,9 +1,12 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.collections.FXCollections;
import javafx.scene.control.*;
@@ -14,8 +17,9 @@ import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.List;
public class AddAccountDialog extends Dialog<StandardAccount> {
public class AddAccountDialog extends Dialog<List<StandardAccount>> {
private final ComboBox<StandardAccount> standardAccountCombo;
private boolean discoverAccounts = false;
public AddAccountDialog(Wallet wallet) {
final DialogPane dialogPane = getDialogPane();
@@ -56,6 +60,18 @@ public class AddAccountDialog extends Dialog<StandardAccount> {
availableAccounts.add(StandardAccount.WHIRLPOOL_PREMIX);
}
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
if(!availableAccounts.isEmpty() && Config.get().getServerType() != ServerType.BITCOIN_CORE &&
(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
|| (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) {
dialogPane.getButtonTypes().add(discoverButtonType);
Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType);
discoverButton.disableProperty().bind(AppServices.onlineProperty().not());
discoverButton.setOnAction(event -> {
discoverAccounts = true;
});
}
standardAccountCombo.setItems(FXCollections.observableList(availableAccounts));
standardAccountCombo.setConverter(new StringConverter<>() {
@Override
@@ -86,6 +102,10 @@ public class AddAccountDialog extends Dialog<StandardAccount> {
content.getChildren().add(standardAccountCombo);
dialogPane.setContent(content);
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? standardAccountCombo.getValue() : null);
setResultConverter(dialogButton -> dialogButton == ButtonType.OK ? List.of(standardAccountCombo.getValue()) : (dialogButton == discoverButtonType ? availableAccounts : null));
}
public boolean isDiscoverAccounts() {
return discoverAccounts;
}
}
@@ -48,7 +48,7 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
}
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate) {
return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (duplicate ? " (Duplicate address)" : "");
return utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : "");
}
public static Glyph getDuplicateGlyph() {
@@ -0,0 +1,44 @@
package com.sparrowwallet.sparrow.control;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.scene.Node;
import javafx.util.Duration;
public class AnimationUtil {
public static Timeline getSlowFadeOut(Node node, Duration duration, double fromValue, int numIncrements) {
Timeline fadeTimeline = new Timeline();
Duration incrementDuration = duration.divide(numIncrements);
for(int i = 0; i < numIncrements; i++) {
double percent = ((double)numIncrements - i - 1) / numIncrements;
double opacity = percent * fromValue;
fadeTimeline.getKeyFrames().add(new KeyFrame(incrementDuration.multiply(i+1), event -> node.setOpacity(opacity)));
}
return fadeTimeline;
}
public static Timeline getPulse(Node node, Duration duration, double fromValue, double toValue, int numIncrements) {
Timeline pulseTimeline = getFade(node, duration, fromValue, toValue, numIncrements);
pulseTimeline.setCycleCount(Animation.INDEFINITE);
pulseTimeline.setAutoReverse(true);
return pulseTimeline;
}
public static Timeline getFade(Node node, Duration duration, double fromValue, double toValue, int numIncrements) {
Timeline fadeTimeline = new Timeline();
Duration incrementDuration = duration.divide(numIncrements);
for(int i = 0; i < numIncrements; i++) {
double percent = ((double) numIncrements - i - 1) / numIncrements; //From 99% to 0%
double opacity = (percent * (fromValue - toValue)) + toValue;
fadeTimeline.getKeyFrames().add(new KeyFrame(incrementDuration.multiply(i+1), event -> node.setOpacity(opacity)));
}
return fadeTimeline;
}
public record AnimatedNode (Node node, Timeline timeline) {}
}
@@ -0,0 +1,71 @@
package com.sparrowwallet.sparrow.control;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.controlsfx.control.textfield.CustomTextField;
public class ComboBoxTextField extends CustomTextField {
private final ObjectProperty<ComboBox<?>> comboProperty = new SimpleObjectProperty<>();
private boolean initialized;
private boolean comboShowing;
public ComboBoxTextField() {
super();
getStyleClass().add("combo-text-field");
setupComboButtonField(super.rightProperty());
disabledProperty().addListener((observable, oldValue, newValue) -> {
if(comboProperty.isNotNull().get()) {
comboProperty.get().setVisible(!newValue);
}
});
}
private void setupComboButtonField(ObjectProperty<Node> rightProperty) {
Region showComboButton = new Region();
showComboButton.getStyleClass().addAll("graphic"); //$NON-NLS-1$
StackPane showComboButtonPane = new StackPane(showComboButton);
showComboButtonPane.getStyleClass().addAll("combo-button"); //$NON-NLS-1$
showComboButtonPane.setCursor(Cursor.DEFAULT);
showComboButtonPane.setOnMouseReleased(e -> {
if(comboProperty.isNotNull().get()) {
if(comboShowing) {
comboProperty.get().hide();
} else {
comboProperty.get().show();
}
comboShowing = !comboShowing;
if(!initialized) {
comboProperty.get().valueProperty().addListener((observable, oldValue, newValue) -> {
comboShowing = false;
Platform.runLater(() -> comboProperty.get().getSelectionModel().clearSelection());
});
initialized = true;
}
}
});
rightProperty.set(showComboButtonPane);
}
public ComboBox<?> getComboProperty() {
return comboProperty.get();
}
public ObjectProperty<ComboBox<?>> walletComboProperty() {
return comboProperty;
}
public void setComboProperty(ComboBox<?> comboProperty) {
this.comboProperty.set(comboProperty);
}
}
@@ -86,10 +86,10 @@ public class ConfirmationProgressIndicator extends StackPane {
upTickLineTimeline.getKeyFrames().add(upTickLineFrame);
sequence.getChildren().add(upTickLineTimeline);
FadeTransition groupFadeOut = new FadeTransition(Duration.minutes(10), confirmationGroup);
groupFadeOut.setFromValue(1);
groupFadeOut.setToValue(0);
Timeline groupFadeOut = AnimationUtil.getSlowFadeOut(confirmationGroup, Duration.minutes(10), 1.0, 10);
sequence.getChildren().add(groupFadeOut);
confirmationsProperty().unbind();
}
sequence.play();
@@ -1,13 +1,19 @@
package com.sparrowwallet.sparrow.control;
import javafx.animation.FadeTransition;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
@@ -16,10 +22,36 @@ import org.controlsfx.control.textfield.CustomTextField;
public class CopyableTextField extends CustomTextField {
private static final Duration FADE_DURATION = Duration.millis(350);
private final ChangeListener<String> selectionListener = (textObservable, textOldValue, textNewValue) -> {
if(!textNewValue.isEmpty()) {
deselect();
}
};
private final EventHandler<MouseEvent> copyHandler = event -> {
ClipboardContent content = new ClipboardContent();
content.putString(getCopyText());
Clipboard.getSystemClipboard().setContent(content);
Tooltip tooltip = new Tooltip("Copied!");
tooltip.show(this, event.getScreenX(), event.getScreenY());
Timeline timeline = new Timeline(new KeyFrame(Duration.seconds(1), e -> tooltip.hide()));
timeline.play();
};
public CopyableTextField() {
super();
getStyleClass().add("copyable-text-field");
setupCopyButtonField(super.rightProperty());
editableProperty().addListener((observable, oldValue, editable) -> {
if(!editable) {
setOnMouseClicked(copyHandler);
selectedTextProperty().addListener(selectionListener);
} else {
setOnMouseClicked(null);
selectedTextProperty().removeListener(selectionListener);
}
});
}
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
@@ -31,7 +63,7 @@ public class CopyableTextField extends CustomTextField {
copyButtonPane.setCursor(Cursor.DEFAULT);
copyButtonPane.setOnMouseReleased(e -> {
ClipboardContent content = new ClipboardContent();
content.putString(getText());
content.putString(getCopyText());
Clipboard.getSystemClipboard().setContent(content);
});
@@ -61,4 +93,8 @@ public class CopyableTextField extends CustomTextField {
}
});
}
protected String getCopyText() {
return getText();
}
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
@@ -79,7 +80,7 @@ public class DescriptorArea extends CodeArea {
copyOutputDescriptor.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(OutputDescriptor.getOutputDescriptor(wallet).toString(true));
content.putString(OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null).toString(true));
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyOutputDescriptor);
@@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoresDiscoveredEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class DeviceKeystoreDiscoverDialog extends DeviceDialog<Map<StandardAccount, Keystore>> {
private final Wallet masterWallet;
private final List<StandardAccount> availableAccounts;
public DeviceKeystoreDiscoverDialog(List<String> operationFingerprints, Wallet masterWallet, List<StandardAccount> availableAccounts) {
super(operationFingerprints);
this.masterWallet = masterWallet;
this.availableAccounts = availableAccounts;
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
setResultConverter(dialogButton -> dialogButton.getButtonData().isCancelButton() ? null : Collections.emptyMap());
}
@Override
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(masterWallet, availableAccounts, device, defaultDevice);
}
@Subscribe
public void keystoresDiscovered(KeystoresDiscoveredEvent event) {
setResult(event.getDiscoveredKeystores());
}
}
@@ -10,12 +10,14 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.geometry.Insets;
@@ -33,8 +35,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
public class DevicePane extends TitledDescriptionPane {
@@ -46,6 +47,7 @@ public class DevicePane extends TitledDescriptionPane {
private final OutputDescriptor outputDescriptor;
private final KeyDerivation keyDerivation;
private final String message;
private final List<StandardAccount> availableAccounts;
private final Device device;
private CustomPasswordField pinField;
@@ -56,6 +58,7 @@ public class DevicePane extends TitledDescriptionPane {
private Button signButton;
private Button displayAddressButton;
private Button signMessageButton;
private Button discoverKeystoresButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
@@ -69,6 +72,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = null;
this.keyDerivation = requiredDerivation;
this.message = null;
this.availableAccounts = null;
this.device = device;
this.defaultDevice = defaultDevice;
@@ -91,6 +95,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.availableAccounts = null;
this.device = device;
this.defaultDevice = defaultDevice;
@@ -113,6 +118,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = outputDescriptor;
this.keyDerivation = null;
this.message = null;
this.availableAccounts = null;
this.device = device;
this.defaultDevice = defaultDevice;
@@ -135,6 +141,7 @@ public class DevicePane extends TitledDescriptionPane {
this.outputDescriptor = null;
this.keyDerivation = keyDerivation;
this.message = message;
this.availableAccounts = null;
this.device = device;
this.defaultDevice = defaultDevice;
@@ -149,6 +156,29 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
public DevicePane(Wallet wallet, List<StandardAccount> availableAccounts, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.DISCOVER_KEYSTORES;
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
this.availableAccounts = availableAccounts;
setDefaultStatus();
showHideLink.setVisible(false);
createSetPassphraseButton();
createDiscoverKeystoresButton();
initialise(device);
buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton);
}
private void initialise(Device device) {
if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice);
@@ -281,6 +311,17 @@ public class DevicePane extends TitledDescriptionPane {
}
}
private void createDiscoverKeystoresButton() {
discoverKeystoresButton = new Button("Discover");
discoverKeystoresButton.setAlignment(Pos.CENTER_RIGHT);
discoverKeystoresButton.setOnAction(event -> {
discoverKeystoresButton.setDisable(true);
discoverKeystores();
});
discoverKeystoresButton.managedProperty().bind(discoverKeystoresButton.visibleProperty());
discoverKeystoresButton.setVisible(false);
}
private void unlock(Device device) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
@@ -291,7 +332,7 @@ public class DevicePane extends TitledDescriptionPane {
VBox vBox = new VBox();
vBox.setMaxHeight(120);
vBox.setSpacing(42);
pinField = (CustomPasswordField)TextFields.createClearablePasswordField();
pinField = new ViewPasswordField();
Platform.runLater(() -> pinField.requestFocus());
enterPinButton = new Button("Enter PIN");
enterPinButton.setDefaultButton(true);
@@ -331,7 +372,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private Node getPassphraseEntry() {
CustomPasswordField passphraseField = (CustomPasswordField)TextFields.createClearablePasswordField();
CustomPasswordField passphraseField = new ViewPasswordField();
passphrase.bind(passphraseField.textProperty());
HBox.setHgrow(passphraseField, Priority.ALWAYS);
passphraseField.setOnAction(event -> {
@@ -584,7 +625,7 @@ public class DevicePane extends TitledDescriptionPane {
});
signPSBTService.setOnFailed(workerStateEvent -> {
setError("Signing Error", signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage());
log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException());
signButton.setDisable(false);
});
setDescription("Signing...");
@@ -620,6 +661,63 @@ public class DevicePane extends TitledDescriptionPane {
signMessageService.start();
}
private void discoverKeystores() {
if(wallet.getKeystores().size() != 1) {
setError("Could not discover keystores", "Only single signature wallets are supported for keystore discovery");
return;
}
String masterFingerprint = wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint();
Wallet copyWallet = wallet.copy();
Map<StandardAccount, String> accountDerivationPaths = new LinkedHashMap<>();
for(StandardAccount availableAccount : availableAccounts) {
Wallet availableWallet = copyWallet.addChildWallet(availableAccount);
Keystore availableKeystore = availableWallet.getKeystores().get(0);
String derivationPath = availableKeystore.getKeyDerivation().getDerivationPath();
accountDerivationPaths.put(availableAccount, derivationPath);
}
Map<StandardAccount, Keystore> importedKeystores = new LinkedHashMap<>();
Hwi.GetXpubsService getXpubsService = new Hwi.GetXpubsService(device, passphrase.get(), accountDerivationPaths);
getXpubsService.setOnSucceeded(workerStateEvent -> {
Map<StandardAccount, String> accountXpubs = getXpubsService.getValue();
for(Map.Entry<StandardAccount, String> entry : accountXpubs.entrySet()) {
try {
Keystore keystore = new Keystore();
keystore.setLabel(device.getModel().toDisplayString());
keystore.setSource(KeystoreSource.HW_USB);
keystore.setWalletModel(device.getModel());
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, accountDerivationPaths.get(entry.getKey())));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(entry.getValue()));
importedKeystores.put(entry.getKey(), keystore);
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
}
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(wallet, importedKeystores);
walletDiscoveryService.setOnSucceeded(event -> {
importedKeystores.keySet().retainAll(walletDiscoveryService.getValue());
EventManager.get().post(new KeystoresDiscoveredEvent(importedKeystores));
});
walletDiscoveryService.setOnFailed(event -> {
log.error("Failed to discover accounts", event.getSource().getException());
setError("Failed to discover accounts", event.getSource().getException().getMessage());
discoverKeystoresButton.setDisable(false);
});
walletDiscoveryService.start();
});
getXpubsService.setOnFailed(workerStateEvent -> {
setError("Could not retrieve xpub", getXpubsService.getException().getMessage());
discoverKeystoresButton.setDisable(false);
});
setDescription("Discovering...");
showHideLink.setVisible(false);
getXpubsService.start();
}
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
@@ -642,6 +740,10 @@ public class DevicePane extends TitledDescriptionPane {
signMessageButton.setDefaultButton(defaultDevice);
signMessageButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.DISCOVER_KEYSTORES)) {
discoverKeystoresButton.setDefaultButton(defaultDevice);
discoverKeystoresButton.setVisible(true);
showHideLink.setVisible(false);
}
}
@@ -689,6 +791,6 @@ public class DevicePane extends TitledDescriptionPane {
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE;
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES;
}
}
@@ -106,7 +106,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
Tooltip tooltip = new Tooltip();
tooltip.setText(nodeEntry.getNode().getDerivationPath().replace("m", ".."));
tooltip.setText(nodeEntry.getNode().toString());
setTooltip(tooltip);
getStyleClass().add("address-cell");
@@ -190,7 +190,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException;
private Node getPasswordEntry(File file) {
CustomPasswordField passwordField = (CustomPasswordField) TextFields.createClearablePasswordField();
CustomPasswordField passwordField = new ViewPasswordField();
passwordField.setPromptText("Wallet password");
password.bind(passwordField.textProperty());
HBox.setHgrow(passwordField, Priority.ALWAYS);
@@ -22,6 +22,7 @@ import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.util.StringConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -97,13 +98,29 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
private Node getScriptTypeEntry(List<ScriptType> scriptTypes) {
Label label = new Label("Script Type:");
HBox fieldBox = new HBox(5);
fieldBox.setAlignment(Pos.CENTER_RIGHT);
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(scriptTypes));
if(scriptTypes.contains(ScriptType.P2WPKH)) {
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
}
scriptTypeComboBox.setConverter(new StringConverter<>() {
@Override
public String toString(ScriptType scriptType) {
return scriptType == null ? "" : scriptType.getDescription();
}
@Override
public ScriptType fromString(String string) {
return null;
}
});
scriptTypeComboBox.setMaxWidth(170);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
fieldBox.getChildren().addAll(scriptTypeComboBox, helpLabel);
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
@@ -130,7 +147,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.CENTER_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().addAll(label, scriptTypeComboBox, helpLabel, region, importFileButton);
contentBox.getChildren().addAll(label, fieldBox, region, importFileButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
@@ -25,10 +25,10 @@ public class KeystorePassphraseDialog extends Dialog<String> {
}
public KeystorePassphraseDialog(String walletName, Keystore keystore, boolean confirm) {
this.passphrase = (CustomPasswordField) TextFields.createClearablePasswordField();
this.passphrase = new ViewPasswordField();
final DialogPane dialogPane = getDialogPane();
setTitle("Keystore Passphrase" + (walletName != null ? " - " + walletName : ""));
setTitle("Keystore Passphrase" + (walletName != null ? " for " + walletName : ""));
dialogPane.setHeaderText((confirm ? "Re-enter" : "Enter") + " the BIP39 passphrase\n" + (confirm ? "to confirm:" : "for keystore: " + keystore.getLabel()));
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
@@ -10,12 +10,16 @@ import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolException;
import javafx.animation.Timeline;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
private static final int ERROR_DISPLAY_MILLIS = 5 * 60 * 1000;
public MixStatusCell() {
super();
setAlignment(Pos.CENTER_RIGHT);
@@ -44,9 +48,9 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
if(mixStatus.getNextMixUtxo() != null) {
setMixSuccess(mixStatus.getNextMixUtxo());
} else if(mixStatus.getMixFailReason() != null) {
setMixFail(mixStatus.getMixFailReason(), mixStatus.getMixError());
setMixFail(mixStatus.getMixFailReason(), mixStatus.getMixError(), mixStatus.getMixErrorTimestamp());
} else if(mixStatus.getMixProgress() != null) {
setMixProgress(mixStatus.getMixProgress());
setMixProgress(mixStatus.getUtxoEntry(), mixStatus.getMixProgress());
} else {
setGraphic(null);
setTooltip(null);
@@ -63,15 +67,30 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
setTooltip(tt);
}
private void setMixFail(MixFailReason mixFailReason, String mixError) {
private void setMixFail(MixFailReason mixFailReason, String mixError, Long mixErrorTimestamp) {
if(mixFailReason != MixFailReason.CANCEL) {
setGraphic(getFailGlyph());
long elapsed = mixErrorTimestamp == null ? 0L : System.currentTimeMillis() - mixErrorTimestamp;
if(elapsed >= ERROR_DISPLAY_MILLIS) {
//Old error, don't set again.
return;
}
Glyph failGlyph = getFailGlyph();
setGraphic(failGlyph);
Tooltip tt = new Tooltip();
tt.setText(mixFailReason.getMessage() + (mixError == null ? "" : ": " + mixError) +
"\nMix failures are generally caused by peers disconnecting during a mix." +
"\nMake sure your internet connection is stable and the computer is configured to prevent sleeping." +
"\nTo prevent sleeping, use the " + getPlatformSleepConfig() + " or enable the function in the Tools menu.");
setTooltip(tt);
Duration fadeDuration = Duration.millis(ERROR_DISPLAY_MILLIS - elapsed);
double fadeFromValue = 1.0 - ((double)elapsed / ERROR_DISPLAY_MILLIS);
Timeline timeline = AnimationUtil.getSlowFadeOut(failGlyph, fadeDuration, fadeFromValue, 10);
timeline.setOnFinished(event -> {
setTooltip(null);
});
timeline.play();
} else {
setGraphic(null);
setTooltip(null);
@@ -89,14 +108,28 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
return "system power settings";
}
private void setMixProgress(MixProgress mixProgress) {
private void setMixProgress(UtxoEntry utxoEntry, MixProgress mixProgress) {
if(mixProgress.getMixStep() != MixStep.FAIL) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(mixProgress.getMixStep().getProgressPercent() == 100 ? -1 : mixProgress.getMixStep().getProgressPercent() / 100.0);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
tt.setText(mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase() + mixProgress.getMixStep().getMessage().substring(1));
String status = mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase() + mixProgress.getMixStep().getMessage().substring(1);
tt.setText(status);
setTooltip(tt);
if(mixProgress.getMixStep() == MixStep.REGISTERED_INPUT) {
tt.setOnShowing(event -> {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
Whirlpool.RegisteredInputsService registeredInputsService = new Whirlpool.RegisteredInputsService(whirlpool, mixProgress.getPoolId());
registeredInputsService.setOnSucceeded(eventStateHandler -> {
if(registeredInputsService.getValue() != null) {
tt.setText(status + " (1 of " + registeredInputsService.getValue() + ")");
}
});
registeredInputsService.start();
});
}
} else {
setGraphic(null);
setTooltip(null);
@@ -0,0 +1,130 @@
package com.sparrowwallet.sparrow.control;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.ImagePattern;
import javafx.scene.shape.Circle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.net.Proxy;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class PayNymAvatar extends StackPane {
private static final Logger log = LoggerFactory.getLogger(PayNymAvatar.class);
private final ObjectProperty<PaymentCode> paymentCodeProperty = new SimpleObjectProperty<>(null);
private static final Map<String, Image> paymentCodeCache = Collections.synchronizedMap(new HashMap<>());
private static final Map<String, Object> paymentCodeLoading = Collections.synchronizedMap(new HashMap<>());
public PayNymAvatar() {
super();
paymentCodeProperty.addListener((observable, oldValue, paymentCode) -> {
if(paymentCode == null) {
getChildren().clear();
} else if(Config.get().isUsePayNym() && (oldValue == null || !oldValue.toString().equals(paymentCode.toString()))) {
String cacheId = getCacheId(paymentCode, getPrefWidth());
if(paymentCodeCache.containsKey(cacheId)) {
setImage(paymentCodeCache.get(cacheId));
} else {
PayNymAvatarService payNymAvatarService = new PayNymAvatarService(paymentCode, getPrefWidth());
payNymAvatarService.setOnRunning(runningEvent -> {
getChildren().clear();
});
payNymAvatarService.setOnSucceeded(successEvent -> {
setImage(payNymAvatarService.getValue());
});
payNymAvatarService.setOnFailed(failedEvent -> {
log.error("Error", failedEvent.getSource().getException());
});
payNymAvatarService.start();
}
}
});
}
private void setImage(Image image) {
getChildren().clear();
Circle circle = new Circle(getPrefWidth() / 2,getPrefHeight() / 2,getPrefWidth() / 2);
circle.setFill(new ImagePattern(image));
getChildren().add(circle);
}
public PaymentCode getPaymentCode() {
return paymentCodeProperty.get();
}
public ObjectProperty<PaymentCode> paymentCodeProperty() {
return paymentCodeProperty;
}
public void setPaymentCode(PaymentCode paymentCode) {
this.paymentCodeProperty.set(paymentCode);
}
private static String getCacheId(PaymentCode paymentCode, double width) {
return paymentCode.toString();
}
private static class PayNymAvatarService extends Service<Image> {
private final PaymentCode paymentCode;
private final double width;
public PayNymAvatarService(PaymentCode paymentCode, double width) {
this.paymentCode = paymentCode;
this.width = width;
}
@Override
protected Task<Image> createTask() {
return new Task<>() {
@Override
protected Image call() throws Exception {
String paymentCodeStr = paymentCode.toString();
String cacheId = getCacheId(paymentCode, width);
Object lock = paymentCodeLoading.get(cacheId);
if(lock != null) {
synchronized(lock) {
if(paymentCodeCache.containsKey(cacheId)) {
return paymentCodeCache.get(cacheId);
}
}
} else {
lock = new Object();
paymentCodeLoading.put(cacheId, lock);
}
synchronized(lock) {
String url = "https://paynym.is/" + paymentCodeStr + "/avatar";
Proxy proxy = AppServices.getProxy();
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false);
paymentCodeCache.put(cacheId, image);
return image;
} catch(Exception e) {
log.debug("Error loading PayNym avatar", e);
throw e;
} finally {
lock.notifyAll();
}
}
}
};
}
}
}
@@ -0,0 +1,62 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.soroban.PayNym;
import com.sparrowwallet.sparrow.soroban.PayNymController;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
public class PayNymCell extends ListCell<PayNym> {
private final PayNymController payNymController;
public PayNymCell(PayNymController payNymController) {
super();
setAlignment(Pos.CENTER_LEFT);
setContentDisplay(ContentDisplay.LEFT);
getStyleClass().add("paynym-cell");
setPrefHeight(50);
this.payNymController = payNymController;
}
@Override
protected void updateItem(PayNym payNym, boolean empty) {
super.updateItem(payNym, empty);
if(empty || payNym == null) {
setText(null);
setGraphic(null);
} else {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(5, 5,5, 5));
PayNymAvatar payNymAvatar = new PayNymAvatar();
payNymAvatar.setPrefWidth(30);
payNymAvatar.setPrefHeight(30);
payNymAvatar.setPaymentCode(payNym.paymentCode());
HBox labelBox = new HBox();
labelBox.setAlignment(Pos.CENTER);
Label label = new Label(payNym.nymName(), payNymAvatar);
label.setGraphicTextGap(10);
labelBox.getChildren().add(label);
pane.setLeft(labelBox);
if(getListView().getUserData() == Boolean.TRUE) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
Button button = new Button("Add Contact");
hBox.getChildren().add(button);
pane.setRight(hBox);
button.setOnAction(event -> {
button.setDisable(true);
payNymController.followPayNym(payNym.paymentCode());
});
}
setText(null);
setGraphic(pane);
}
}
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.control;
import com.samourai.wallet.bip47.rpc.PaymentCode;
public class PaymentCodeTextField extends CopyableTextField {
private String paymentCodeStr;
public void setPaymentCode(PaymentCode paymentCode) {
this.paymentCodeStr = paymentCode.toString();
String abbrevPcode = paymentCodeStr.substring(0, 12) + "..." + paymentCodeStr.substring(paymentCodeStr.length() - 5);
setText(abbrevPcode);
}
@Override
protected String getCopyText() {
return paymentCodeStr;
}
}
@@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.control;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.control.ProgressIndicator;
import javafx.util.Duration;
public class ProgressTimer extends ProgressIndicator {
private final IntegerProperty secondsProperty = new SimpleIntegerProperty(60);
private Timeline timeline;
public ProgressTimer() {
super(0);
getStyleClass().add("progress-timer");
}
public void start() {
start(e -> {});
}
public void start(EventHandler<ActionEvent> onFinished) {
timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(progressProperty(), 0)),
new KeyFrame(Duration.seconds(getSeconds() * 0.8), e -> getStyleClass().add("warn")),
new KeyFrame(Duration.seconds(getSeconds()), onFinished, new KeyValue(progressProperty(), 1)));
timeline.setCycleCount(1);
timeline.play();
}
public void stop() {
if(timeline != null) {
timeline.stop();
}
}
public int getSeconds() {
return secondsProperty.get();
}
public IntegerProperty secondsProperty() {
return secondsProperty;
}
public void setSeconds(int secondsProperty) {
this.secondsProperty.set(secondsProperty);
}
}
@@ -499,7 +499,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private ScriptType getScriptType(List<ScriptExpression> scriptExpressions) {
List<ScriptExpression> expressions = new ArrayList<>(scriptExpressions);
if(expressions.get(expressions.size() - 1) == ScriptExpression.MULTISIG || expressions.get(expressions.size() - 1) == ScriptExpression.SORTED_MULTISIG) {
if(expressions.get(expressions.size() - 1) == ScriptExpression.MULTISIG
|| expressions.get(expressions.size() - 1) == ScriptExpression.SORTED_MULTISIG
|| expressions.get(expressions.size() - 1) == ScriptExpression.COSIGNER) {
expressions.remove(expressions.size() - 1);
}
@@ -515,6 +517,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
return ScriptType.P2SH_P2WSH;
} else if(List.of(ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
return ScriptType.P2WSH;
} else if(List.of(ScriptExpression.TAPROOT).equals(expressions)) {
return ScriptType.P2TR;
}
throw new IllegalArgumentException("Unknown script of " + expressions);
@@ -3,16 +3,21 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.event.SorobanInitiatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
@@ -32,12 +37,17 @@ import java.util.*;
import java.util.stream.Collectors;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 7;
private static final int MAX_PAYMENTS = 5;
private static final double DIAGRAM_HEIGHT = 215.0;
private static final int MAX_UTXOS = 8;
private static final int REDUCED_MAX_UTXOS = MAX_UTXOS - 2;
private static final int MAX_PAYMENTS = 6;
private static final int REDUCED_MAX_PAYMENTS = MAX_PAYMENTS - 2;
private static final double DIAGRAM_HEIGHT = 210.0;
private static final double REDUCED_DIAGRAM_HEIGHT = DIAGRAM_HEIGHT - 60;
private static final int TOOLTIP_SHOW_DELAY = 50;
private WalletTransaction walletTx;
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
@@ -76,15 +86,15 @@ public class TransactionDiagram extends GridPane {
}
public void update() {
Map<BlockTransactionHashIndex, WalletNode> displayedUtxos = getDisplayedUtxos();
List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets = getDisplayedUtxoSets();
Pane inputsTypePane = getInputsType(displayedUtxos);
Pane inputsTypePane = getInputsType(displayedUtxoSets);
GridPane.setConstraints(inputsTypePane, 0, 0);
Pane inputsPane = getInputsLabels(displayedUtxos);
Pane inputsPane = getInputsLabels(displayedUtxoSets);
GridPane.setConstraints(inputsPane, 1, 0);
Node inputsLinesPane = getInputsLines(displayedUtxos);
Node inputsLinesPane = getInputsLines(displayedUtxoSets);
GridPane.setConstraints(inputsLinesPane, 2, 0);
Pane txPane = getTransactionPane();
@@ -102,19 +112,57 @@ public class TransactionDiagram extends GridPane {
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
}
private Map<BlockTransactionHashIndex, WalletNode> getDisplayedUtxos() {
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = walletTx.getSelectedUtxos();
private List<Map<BlockTransactionHashIndex, WalletNode>> getDisplayedUtxoSets() {
boolean addUserSet = getOptimizationStrategy() == OptimizationStrategy.PRIVACY && SorobanServices.canWalletMix(walletTx.getWallet())
&& walletTx.getPayments().size() == 1
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getAddress(walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
if(getPayjoinURI() != null) {
List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets = new ArrayList<>();
for(Map<BlockTransactionHashIndex, WalletNode> selectedUtxoSet : walletTx.getSelectedUtxoSets()) {
displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, addUserSet ? 2 : walletTx.getSelectedUtxoSets().size()));
}
if(addUserSet && displayedUtxoSets.size() == 1) {
Map<BlockTransactionHashIndex, WalletNode> addUserUtxoSet = new HashMap<>();
addUserUtxoSet.put(new AddUserBlockTransactionHashIndex(!walletTx.isTwoPersonCoinjoin()), null);
displayedUtxoSets.add(addUserUtxoSet);
}
List<Map<BlockTransactionHashIndex, WalletNode>> paddedUtxoSets = new ArrayList<>();
int maxDisplayedSetSize = displayedUtxoSets.stream().mapToInt(Map::size).max().orElse(0);
for(Map<BlockTransactionHashIndex, WalletNode> selectedUtxoSet : displayedUtxoSets) {
int toAdd = maxDisplayedSetSize - selectedUtxoSet.size();
if(toAdd > 0) {
Map<BlockTransactionHashIndex, WalletNode> paddedUtxoSet = new LinkedHashMap<>();
int firstAdd = toAdd / 2;
for(int i = 0; i < firstAdd; i++) {
paddedUtxoSet.put(new InvisibleBlockTransactionHashIndex(i), null);
}
paddedUtxoSet.putAll(selectedUtxoSet);
for(int i = firstAdd; i < toAdd; i++) {
paddedUtxoSet.put(new InvisibleBlockTransactionHashIndex(i), null);
}
paddedUtxoSets.add(paddedUtxoSet);
} else {
paddedUtxoSets.add(selectedUtxoSet);
}
}
return paddedUtxoSets;
}
private Map<BlockTransactionHashIndex, WalletNode> getDisplayedUtxos(Map<BlockTransactionHashIndex, WalletNode> selectedUtxos, int numSets) {
if(getPayjoinURI() != null && !selectedUtxos.containsValue(null)) {
selectedUtxos = new LinkedHashMap<>(selectedUtxos);
selectedUtxos.put(new PayjoinBlockTransactionHashIndex(), null);
}
if(selectedUtxos.size() > MAX_UTXOS) {
int maxUtxosPerSet = getMaxUtxos() / numSets;
if(selectedUtxos.size() > maxUtxosPerSet) {
Map<BlockTransactionHashIndex, WalletNode> utxos = new LinkedHashMap<>();
List<BlockTransactionHashIndex> additional = new ArrayList<>();
for(BlockTransactionHashIndex reference : selectedUtxos.keySet()) {
if(utxos.size() < MAX_UTXOS - 1) {
if(utxos.size() < maxUtxosPerSet - 1) {
utxos.put(reference, selectedUtxos.get(reference));
} else {
additional.add(reference);
@@ -144,62 +192,98 @@ public class TransactionDiagram extends GridPane {
return null;
}
private Pane getInputsType(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
StackPane stackPane = new StackPane();
private Pane getInputsType(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
double width = 22.0;
double height = getDiagramHeight() - 10;
if(walletTx.isCoinControlUsed()) {
VBox pane = new VBox();
double width = 22.0;
Group group = new Group();
VBox.setVgrow(group, Priority.ALWAYS);
VBox allBrackets = new VBox(10);
allBrackets.setPrefWidth(width);
allBrackets.setPadding(new Insets(5, 0, 5, 0));
allBrackets.setAlignment(Pos.CENTER);
Line widthLine = new Line();
widthLine.setStartX(0);
widthLine.setEndX(width);
widthLine.getStyleClass().add("boundary");
Line topYaxis = new Line();
topYaxis.setStartX(width * 0.5);
topYaxis.setStartY(getDiagramHeight() * 0.5 - 20.0);
topYaxis.setEndX(width * 0.5);
topYaxis.setEndY(10);
topYaxis.getStyleClass().add("inputs-type");
Line topBracket = new Line();
topBracket.setStartX(width * 0.5);
topBracket.setStartY(10);
topBracket.setEndX(width);
topBracket.setEndY(10);
topBracket.getStyleClass().add("inputs-type");
Line bottomYaxis = new Line();
bottomYaxis.setStartX(width * 0.5);
bottomYaxis.setStartY(getDiagramHeight() - 10);
bottomYaxis.setEndX(width * 0.5);
bottomYaxis.setEndY(getDiagramHeight() * 0.5 + 20.0);
bottomYaxis.getStyleClass().add("inputs-type");
Line bottomBracket = new Line();
bottomBracket.setStartX(width * 0.5);
bottomBracket.setStartY(getDiagramHeight() - 10);
bottomBracket.setEndX(width);
bottomBracket.setEndY(getDiagramHeight() - 10);
bottomBracket.getStyleClass().add("inputs-type");
group.getChildren().addAll(widthLine, topYaxis, topBracket, bottomYaxis, bottomBracket);
pane.getChildren().add(group);
Glyph lockGlyph = getLockGlyph();
lockGlyph.getStyleClass().add("inputs-type");
Tooltip tooltip = new Tooltip("Coin control active");
lockGlyph.setTooltip(tooltip);
stackPane.getChildren().addAll(pane, lockGlyph);
int numSets = displayedUtxoSets.size();
if(numSets > 1) {
double setHeight = (height / numSets) - 5;
for(int set = 0; set < numSets; set++) {
boolean externalUserSet = displayedUtxoSets.get(set).values().stream().anyMatch(Objects::nonNull);
boolean addUserSet = displayedUtxoSets.get(set).keySet().stream().anyMatch(ref -> ref instanceof AddUserBlockTransactionHashIndex);
if(externalUserSet || addUserSet) {
boolean replace = !isFinal() && set > 0 && SorobanServices.canWalletMix(walletTx.getWallet());
Glyph bracketGlyph = !replace && walletTx.isCoinControlUsed() ? getLockGlyph() : (addUserSet ? getUserAddGlyph() : getCoinsGlyph(replace));
String tooltipText = addUserSet ? "Click to add a mix partner" : (walletTx.getWallet().getFullDisplayName() + (replace ? "\nClick to replace with a mix partner" : ""));
StackPane stackPane = getBracket(width, setHeight, bracketGlyph, tooltipText);
allBrackets.getChildren().add(stackPane);
} else {
StackPane stackPane = getBracket(width, setHeight, getUserGlyph(), "Mix partner");
allBrackets.getChildren().add(stackPane);
}
}
} else if(walletTx.isCoinControlUsed()) {
StackPane stackPane = getBracket(width, height, getLockGlyph(), "Coin control active");
allBrackets.getChildren().add(stackPane);
}
return allBrackets;
}
private StackPane getBracket(double width, double height, Glyph glyph, String tooltipText) {
StackPane stackPane = new StackPane();
VBox pane = new VBox();
Group group = new Group();
VBox.setVgrow(group, Priority.ALWAYS);
int padding = 0;
double iconPadding = 20.0;
Line widthLine = new Line();
widthLine.setStartX(0);
widthLine.setEndX(width);
widthLine.getStyleClass().add("boundary");
Line topYaxis = new Line();
topYaxis.setStartX(width * 0.5);
topYaxis.setStartY(height * 0.5 - iconPadding);
topYaxis.setEndX(width * 0.5);
topYaxis.setEndY(padding);
topYaxis.getStyleClass().add("inputs-type");
Line topBracket = new Line();
topBracket.setStartX(width * 0.5);
topBracket.setStartY(padding);
topBracket.setEndX(width);
topBracket.setEndY(padding);
topBracket.getStyleClass().add("inputs-type");
Line bottomYaxis = new Line();
bottomYaxis.setStartX(width * 0.5);
bottomYaxis.setStartY(height - padding);
bottomYaxis.setEndX(width * 0.5);
bottomYaxis.setEndY(height * 0.5 + iconPadding);
bottomYaxis.getStyleClass().add("inputs-type");
Line bottomBracket = new Line();
bottomBracket.setStartX(width * 0.5);
bottomBracket.setStartY(height - padding);
bottomBracket.setEndX(width);
bottomBracket.setEndY(height - padding);
bottomBracket.getStyleClass().add("inputs-type");
group.getChildren().addAll(widthLine, topYaxis, topBracket, bottomYaxis, bottomBracket);
pane.getChildren().add(group);
glyph.getStyleClass().add("inputs-type");
Tooltip tooltip = new Tooltip(tooltipText);
tooltip.getStyleClass().add("transaction-tooltip");
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
glyph.setTooltip(tooltip);
stackPane.getChildren().addAll(pane, glyph);
return stackPane;
}
private Pane getInputsLabels(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
private Pane getInputsLabels(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
VBox inputsBox = new VBox();
inputsBox.setMaxWidth(150);
inputsBox.setPrefWidth(150);
@@ -207,47 +291,71 @@ public class TransactionDiagram extends GridPane {
inputsBox.minHeightProperty().bind(minHeightProperty());
inputsBox.setAlignment(Pos.CENTER_RIGHT);
inputsBox.getChildren().add(createSpacer());
for(BlockTransactionHashIndex input : displayedUtxos.keySet()) {
WalletNode walletNode = displayedUtxos.get(input);
String desc = getInputDescription(input);
Label label = new Label(desc);
label.getStyleClass().add("utxo-label");
for(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos : displayedUtxoSets) {
for(BlockTransactionHashIndex input : displayedUtxos.keySet()) {
WalletNode walletNode = displayedUtxos.get(input);
String desc = getInputDescription(input);
Label label = new Label(desc);
label.getStyleClass().add("utxo-label");
Button excludeUtxoButton = new Button("");
excludeUtxoButton.setGraphic(getExcludeGlyph());
excludeUtxoButton.setOnAction(event -> {
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
});
Button excludeUtxoButton = new Button("");
excludeUtxoButton.setGraphic(getExcludeGlyph());
excludeUtxoButton.setOnAction(event -> {
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
});
Tooltip tooltip = new Tooltip();
if(walletNode != null) {
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath().replace("m", "..") + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
tooltip.getStyleClass().add("input-label");
Tooltip tooltip = new Tooltip();
if(walletNode != null) {
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + (isFinal() ? walletTx.getWallet().getFullDisplayName() : "") + " " + walletNode + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
tooltip.getStyleClass().add("input-label");
if(input.getLabel() == null || input.getLabel().isEmpty()) {
label.getStyleClass().add("input-label");
}
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
} else {
if(input instanceof PayjoinBlockTransactionHashIndex) {
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
} else {
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex) input;
StringJoiner joiner = new StringJoiner("\n");
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(getInputDescription(additionalInput));
if(input.getLabel() == null || input.getLabel().isEmpty()) {
label.getStyleClass().add("input-label");
}
tooltip.setText(joiner.toString());
}
tooltip.getStyleClass().add("input-label");
}
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
label.setTooltip(tooltip);
inputsBox.getChildren().add(label);
inputsBox.getChildren().add(createSpacer());
if(!isFinal()) {
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
}
} else {
if(input instanceof PayjoinBlockTransactionHashIndex) {
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
} else if(input instanceof AdditionalBlockTransactionHashIndex additionalReference) {
StringJoiner joiner = new StringJoiner("\n");
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
joiner.add(getInputDescription(additionalInput));
}
tooltip.setText(joiner.toString());
} else if(input instanceof InvisibleBlockTransactionHashIndex) {
tooltip.setText("");
} else if(input instanceof AddUserBlockTransactionHashIndex) {
tooltip.setText("");
label.setGraphic(walletTx.isTwoPersonCoinjoin() ? getQuestionGlyph() : getWarningGlyph());
label.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
});
} else {
if(walletTx.getInputTransactions() != null && walletTx.getInputTransactions().get(input.getHash()) != null) {
BlockTransaction blockTransaction = walletTx.getInputTransactions().get(input.getHash());
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int) input.getIndex());
Address fromAddress = txOutput.getScript().getToAddress();
tooltip.setText("Input of " + getSatsValue(txOutput.getValue()) + " sats\n" + input.getHashAsString() + ":" + input.getIndex() + (fromAddress != null ? "\n" + fromAddress : ""));
} else {
tooltip.setText(input.getHashAsString() + ":" + input.getIndex());
}
label.getStyleClass().add("input-label");
}
tooltip.getStyleClass().add("input-label");
}
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
if(!tooltip.getText().isEmpty()) {
label.setTooltip(tooltip);
}
inputsBox.getChildren().add(label);
inputsBox.getChildren().add(createSpacer());
}
}
return inputsBox;
@@ -261,7 +369,10 @@ public class TransactionDiagram extends GridPane {
return String.format(Locale.ENGLISH, "%,d", amount);
}
private Pane getInputsLines(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
private Pane getInputsLines(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
Map<BlockTransactionHashIndex, WalletNode> displayedUtxos = new LinkedHashMap<>();
displayedUtxoSets.forEach(displayedUtxos::putAll);
VBox pane = new VBox();
Group group = new Group();
VBox.setVgrow(group, Priority.ALWAYS);
@@ -281,8 +392,10 @@ public class TransactionDiagram extends GridPane {
CubicCurve curve = new CubicCurve();
curve.getStyleClass().add("input-line");
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex || inputs.get(numUtxos-i) instanceof AddUserBlockTransactionHashIndex) {
curve.getStyleClass().add("input-dashed-line");
} else if(inputs.get(numUtxos-i) instanceof InvisibleBlockTransactionHashIndex) {
continue;
}
curve.setStartX(0);
@@ -312,11 +425,12 @@ public class TransactionDiagram extends GridPane {
private List<Payment> getDisplayedPayments() {
List<Payment> payments = walletTx.getPayments();
if(payments.size() > MAX_PAYMENTS) {
int maxPayments = getMaxPayments();
if(payments.size() > maxPayments) {
List<Payment> displayedPayments = new ArrayList<>();
List<Payment> additional = new ArrayList<>();
for(Payment payment : payments) {
if(displayedPayments.size() < MAX_PAYMENTS - 1) {
if(displayedPayments.size() < maxPayments - 1) {
displayedPayments.add(payment);
} else {
additional.add(payment);
@@ -376,19 +490,24 @@ public class TransactionDiagram extends GridPane {
outputsBox.setAlignment(Pos.CENTER_LEFT);
outputsBox.getChildren().add(createSpacer());
List<OutputNode> outputNodes = new ArrayList<>();
for(Payment payment : displayedPayments) {
Glyph outputGlyph = getOutputGlyph(payment);
boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment;
String recipientDesc = labelledPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "...";
Label recipientLabel = new Label(recipientDesc, outputGlyph);
payment.setLabel(getOutputLabel(payment));
Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph);
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Tooltip recipientTooltip = new Tooltip((walletTx.isConsolidationSend(payment) ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString()));
Wallet toWallet = getToWallet(payment);
WalletNode toNode = walletTx.getWallet() != null ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null;
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? "\n" + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : "external address") : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
recipientLabel.setTooltip(recipientTooltip);
outputsBox.getChildren().add(recipientLabel);
outputsBox.getChildren().add(createSpacer());
outputNodes.add(new OutputNode(recipientLabel, payment.getAddress()));
}
for(Map.Entry<WalletNode, Long> changeEntry : walletTx.getChangeMap().entrySet()) {
@@ -397,29 +516,42 @@ public class TransactionDiagram extends GridPane {
boolean overGapLimit = (changeNode.getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit();
HBox actionBox = new HBox();
String changeDesc = walletTx.getChangeAddress(changeNode).toString().substring(0, 8) + "...";
Address changeAddress = walletTx.getChangeAddress(changeNode);
String changeDesc = changeAddress.toString().substring(0, 8) + "...";
Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph());
changeLabel.getStyleClass().addAll("output-label", "change-label");
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(changeEntry.getValue()) + " sats to " + changeNode.getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(changeEntry.getValue()) + " sats to " + changeNode + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
changeTooltip.getStyleClass().add("change-label");
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
changeTooltip.setShowDuration(Duration.INDEFINITE);
changeLabel.setTooltip(changeTooltip);
actionBox.getChildren().add(changeLabel);
Button nextChangeAddressButton = new Button("");
nextChangeAddressButton.setGraphic(getChangeReplaceGlyph());
nextChangeAddressButton.setOnAction(event -> {
EventManager.get().post(new ReplaceChangeAddressEvent(walletTx));
});
Tooltip replaceChangeTooltip = new Tooltip("Use next change address");
nextChangeAddressButton.setTooltip(replaceChangeTooltip);
Label replaceChangeLabel = new Label("", nextChangeAddressButton);
replaceChangeLabel.getStyleClass().add("replace-change-label");
replaceChangeLabel.setVisible(false);
actionBox.setOnMouseEntered(event -> replaceChangeLabel.setVisible(true));
actionBox.setOnMouseExited(event -> replaceChangeLabel.setVisible(false));
if(!isFinal()) {
Button nextChangeAddressButton = new Button("");
nextChangeAddressButton.setGraphic(getChangeReplaceGlyph());
nextChangeAddressButton.setOnAction(event -> {
EventManager.get().post(new ReplaceChangeAddressEvent(walletTx));
});
Tooltip replaceChangeTooltip = new Tooltip("Use next change address");
nextChangeAddressButton.setTooltip(replaceChangeTooltip);
Label replaceChangeLabel = new Label("", nextChangeAddressButton);
replaceChangeLabel.getStyleClass().add("replace-change-label");
replaceChangeLabel.setVisible(false);
actionBox.setOnMouseEntered(event -> replaceChangeLabel.setVisible(true));
actionBox.setOnMouseExited(event -> replaceChangeLabel.setVisible(false));
actionBox.getChildren().add(replaceChangeLabel);
}
actionBox.getChildren().addAll(changeLabel, replaceChangeLabel);
outputsBox.getChildren().add(actionBox);
outputNodes.add(new OutputNode(actionBox, changeAddress));
}
if(isFinal()) {
Collections.sort(outputNodes);
}
for(OutputNode outputNode : outputNodes) {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
}
@@ -427,9 +559,10 @@ public class TransactionDiagram extends GridPane {
Label feeLabel = highFee ? new Label("High Fee", getWarningGlyph()) : new Label("Fee", getFeeGlyph());
feeLabel.getStyleClass().addAll("output-label", "fee-label");
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
Tooltip feeTooltip = new Tooltip("Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
feeTooltip.getStyleClass().add("fee-tooltip");
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
feeTooltip.setShowDuration(Duration.INDEFINITE);
feeLabel.setTooltip(feeTooltip);
outputsBox.getChildren().add(feeLabel);
outputsBox.getChildren().add(createSpacer());
@@ -439,14 +572,18 @@ public class TransactionDiagram extends GridPane {
private Pane getTransactionPane() {
VBox txPane = new VBox();
txPane.setPadding(new Insets(0, 10, 0, 10));
txPane.setPadding(new Insets(0, 5, 0, 5));
txPane.setAlignment(Pos.CENTER);
txPane.getChildren().add(createSpacer());
String txDesc = "Transaction";
Label txLabel = new Label(txDesc);
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes");
boolean isFinalized = walletTx.getTransaction().hasScriptSigs() || walletTx.getTransaction().hasWitnesses();
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n"
+ String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes"
+ (walletTx.getFee() < 0 ? "" : "\n" + String.format("%.2f", walletTx.getFee() / walletTx.getTransaction().getVirtualSize()) + " sats/vB" + (isFinalized ? "" : " (non-final)")));
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.setShowDuration(Duration.INDEFINITE);
tooltip.getStyleClass().add("transaction-tooltip");
txLabel.setTooltip(tooltip);
txPane.getChildren().add(txLabel);
@@ -456,21 +593,74 @@ public class TransactionDiagram extends GridPane {
}
public double getDiagramHeight() {
if(AppServices.isReducedWindowHeight(this)) {
return DIAGRAM_HEIGHT - 40;
if(isReducedHeight()) {
return REDUCED_DIAGRAM_HEIGHT;
}
return DIAGRAM_HEIGHT;
}
private int getMaxUtxos() {
if(isReducedHeight()) {
return REDUCED_MAX_UTXOS;
}
return MAX_UTXOS;
}
private int getMaxPayments() {
if(isReducedHeight()) {
return REDUCED_MAX_PAYMENTS;
}
return MAX_PAYMENTS;
}
private boolean isReducedHeight() {
return !isFinal() && AppServices.isReducedWindowHeight(this);
}
private Node createSpacer() {
final Region spacer = new Region();
VBox.setVgrow(spacer, Priority.ALWAYS);
return spacer;
}
private String getOutputLabel(Payment payment) {
if(payment.getLabel() != null) {
return payment.getLabel();
}
if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) {
return "Whirlpool Fee";
} else if(walletTx.isPremixSend(payment)) {
int premixIndex = getOutputIndex(payment.getAddress()) - 2;
return "Premix #" + premixIndex;
} else if(walletTx.isBadbankSend(payment)) {
return "Badbank Change";
}
return null;
}
private int getOutputIndex(Address address) {
return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress())).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow();
}
private Wallet getToWallet(Payment payment) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet != walletTx.getWallet() && openWallet.isValid() && openWallet.isWalletAddress(payment.getAddress())) {
return openWallet;
}
}
return null;
}
public Glyph getOutputGlyph(Payment payment) {
if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
if(payment.getType().equals(Payment.Type.MIX)) {
return getMixGlyph();
} else if(payment.getType().equals(Payment.Type.FAKE_MIX)) {
return getFakeMixGlyph();
} else if(walletTx.isConsolidationSend(payment)) {
return getConsolidationGlyph();
@@ -482,6 +672,8 @@ public class TransactionDiagram extends GridPane {
return getWhirlpoolFeeGlyph();
} else if(payment instanceof AdditionalPayment) {
return ((AdditionalPayment)payment).getOutputGlyph(this);
} else if(getToWallet(payment) != null) {
return getDepositGlyph();
}
return getPaymentGlyph();
@@ -508,6 +700,13 @@ public class TransactionDiagram extends GridPane {
return consolidationGlyph;
}
public static Glyph getDepositGlyph() {
Glyph depositGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
depositGlyph.getStyleClass().add("deposit-icon");
depositGlyph.setFontSize(12);
return depositGlyph;
}
public static Glyph getPremixGlyph() {
Glyph premixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
premixGlyph.getStyleClass().add("premix-icon");
@@ -540,9 +739,9 @@ public class TransactionDiagram extends GridPane {
return getChangeGlyph();
}
public static Glyph getPayjoinGlyph() {
public static Glyph getMixGlyph() {
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
payjoinGlyph.getStyleClass().add("payjoin-icon");
payjoinGlyph.getStyleClass().add("mix-icon");
payjoinGlyph.setFontSize(12);
return payjoinGlyph;
}
@@ -582,6 +781,13 @@ public class TransactionDiagram extends GridPane {
return feeWarningGlyph;
}
private Glyph getQuestionGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QUESTION_CIRCLE);
feeWarningGlyph.getStyleClass().add("question-icon");
feeWarningGlyph.setFontSize(12);
return feeWarningGlyph;
}
private Glyph getLockGlyph() {
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
lockGlyph.getStyleClass().add("lock-icon");
@@ -589,6 +795,76 @@ public class TransactionDiagram extends GridPane {
return lockGlyph;
}
private Glyph getUserGlyph() {
Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER);
userGlyph.getStyleClass().add("user-icon");
userGlyph.setFontSize(12);
return userGlyph;
}
private Glyph getUserAddGlyph() {
Glyph userAddGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER_PLUS);
userAddGlyph.getStyleClass().add("useradd-icon");
userAddGlyph.setFontSize(12);
userAddGlyph.setOnMouseEntered(event -> {
userAddGlyph.setFontSize(18);
});
userAddGlyph.setOnMouseExited(event -> {
userAddGlyph.setFontSize(12);
});
userAddGlyph.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
});
return userAddGlyph;
}
private Glyph getCoinsGlyph(boolean allowReplacement) {
Glyph coinsGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
coinsGlyph.setFontSize(12);
if(allowReplacement) {
coinsGlyph.getStyleClass().add("coins-replace-icon");
coinsGlyph.setOnMouseEntered(event -> {
coinsGlyph.setIcon(FontAwesome5.Glyph.USER_PLUS);
coinsGlyph.setFontSize(18);
});
coinsGlyph.setOnMouseExited(event -> {
coinsGlyph.setIcon(FontAwesome5.Glyph.COINS);
coinsGlyph.setFontSize(12);
});
coinsGlyph.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
});
} else {
coinsGlyph.getStyleClass().add("coins-icon");
}
return coinsGlyph;
}
public boolean isFinal() {
return finalProperty.get();
}
public BooleanProperty finalProperty() {
return finalProperty;
}
public void setFinal(boolean isFinal) {
this.finalProperty.set(isFinal);
}
public OptimizationStrategy getOptimizationStrategy() {
return optimizationStrategyProperty.get();
}
public ObjectProperty<OptimizationStrategy> optimizationStrategyProperty() {
return optimizationStrategyProperty;
}
public void setOptimizationStrategy(OptimizationStrategy optimizationStrategy) {
this.optimizationStrategyProperty.set(optimizationStrategy);
}
private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex {
public PayjoinBlockTransactionHashIndex() {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
@@ -618,6 +894,31 @@ public class TransactionDiagram extends GridPane {
}
}
private static class InvisibleBlockTransactionHashIndex extends BlockTransactionHashIndex {
public InvisibleBlockTransactionHashIndex(int index) {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, index, 0);
}
@Override
public String getLabel() {
return " ";
}
}
private static class AddUserBlockTransactionHashIndex extends BlockTransactionHashIndex {
private final boolean required;
public AddUserBlockTransactionHashIndex(boolean required) {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
this.required = required;
}
@Override
public String getLabel() {
return "Add Mix Partner" + (required ? "" : "?");
}
}
private static class AdditionalPayment extends Payment {
private final List<Payment> additionalPayments;
@@ -644,4 +945,23 @@ public class TransactionDiagram extends GridPane {
return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n"));
}
}
private class OutputNode implements Comparable<OutputNode> {
public Node outputLabel;
public Address address;
public OutputNode(Node outputLabel, Address address) {
this.outputLabel = outputLabel;
this.address = address;
}
@Override
public int compareTo(TransactionDiagram.OutputNode o) {
try {
return getOutputIndex(address) - getOutputIndex(o.address);
} catch(Exception e) {
return 0;
}
}
}
}
@@ -27,13 +27,16 @@ public class UsbStatusButton extends MenuButton {
public void setDevices(List<Device> devices) {
for(Device device : devices) {
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.KEEPKEY)) {
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase " + (device.isNeedsPassphraseSent() ? "Off" : "On"));
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
toggleItem.setOnAction(event -> {
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
togglePassphraseService.setOnSucceeded(event1 -> {
EventManager.get().post(new RequestOpenWalletsEvent());
if(!device.getModel().externalPassphraseEntry()) {
AppServices.showAlertDialog("Reconnect device", "Reconnect your " + device.getModel().toDisplayString() + " to reset the passphrase.", Alert.AlertType.INFORMATION);
}
});
togglePassphraseService.setOnFailed(event1 -> {
AppServices.showErrorDialog("Error toggling passphrase", event1.getSource().getException().getMessage());
@@ -9,14 +9,18 @@ import com.sparrowwallet.sparrow.wallet.WalletUtxosEntry;
import javafx.beans.NamedArg;
import javafx.scene.Node;
import javafx.scene.chart.*;
import javafx.scene.control.Tooltip;
import javafx.util.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
public class UtxosChart extends BarChart<String, Number> {
private static final int MAX_BARS = 8;
private static final String OTHER_CATEGORY = "Other";
private static final int TOOLTIP_SHOW_DELAY = 50;
private List<Entry> selectedEntries;
private int totalUtxos;
@@ -57,9 +61,11 @@ public class UtxosChart extends BarChart<String, Number> {
XYChart.Data<String, Number> existingData = utxoSeries.getData().get(i);
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() instanceof Entry && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
utxoSeries.getData().set(i, newData);
installTooltip(newData);
}
} else {
utxoSeries.getData().add(newData);
installTooltip(newData);
}
}
@@ -74,12 +80,21 @@ public class UtxosChart extends BarChart<String, Number> {
private String getCategoryName(Entry entry) {
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
return entry.getLabel().length() > 15 ? entry.getLabel().substring(0, 15) + "..." + "\n" + ((UtxoEntry)entry).getDescription() : entry.getLabel() + "\n" + ((UtxoEntry)entry).getDescription();
return entry.getLabel() + "\n" + ((UtxoEntry)entry).getDescription();
}
return ((UtxoEntry)entry).getDescription();
}
private void installTooltip(XYChart.Data<String, Number> item) {
Tooltip.uninstall(item.getNode(), null);
String satsValue = String.format(Locale.ENGLISH, "%,d", item.getYValue());
Tooltip tooltip = new Tooltip(item.getXValue() + "\n" + satsValue + " sats");
tooltip.setShowDelay(Duration.millis(TOOLTIP_SHOW_DELAY));
Tooltip.install(item.getNode(), tooltip);
}
public void select(List<Entry> entries) {
Set<Node> selectedBars = lookupAll(".chart-bar.selected");
for(Node selectedBar : selectedBars) {
@@ -0,0 +1,28 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.property.ObjectProperty;
import javafx.scene.Node;
import javafx.scene.control.Skin;
import org.controlsfx.control.textfield.CustomPasswordField;
public class ViewPasswordField extends CustomPasswordField {
public ViewPasswordField() {
super();
getStyleClass().add("view-password-text-field");
}
@Override
protected Skin<?> createDefaultSkin() {
return new ViewPasswordFieldSkin(this) {
@Override
public ObjectProperty<Node> leftProperty() {
return ViewPasswordField.this.leftProperty();
}
@Override
public ObjectProperty<Node> rightProperty() {
return ViewPasswordField.this.rightProperty();
}
};
}
}
@@ -0,0 +1,83 @@
package com.sparrowwallet.sparrow.control;
import impl.org.controlsfx.skin.CustomTextFieldSkin;
import javafx.animation.FadeTransition;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.scene.Cursor;
import javafx.scene.control.PasswordField;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.controlsfx.control.textfield.CustomPasswordField;
public abstract class ViewPasswordFieldSkin extends CustomTextFieldSkin {
private static final Duration FADE_DURATION = Duration.millis(350);
public static final char BULLET = '\u25CF';
private boolean mask = true;
public ViewPasswordFieldSkin(CustomPasswordField textField) {
super(textField);
Region viewPasswordButton = new Region();
viewPasswordButton.getStyleClass().addAll("graphic");
StackPane viewPasswordButtonPane = new StackPane(viewPasswordButton);
viewPasswordButtonPane.getStyleClass().addAll("view-password-button");
viewPasswordButtonPane.setOpacity(0.0);
viewPasswordButtonPane.setCursor(Cursor.DEFAULT);
viewPasswordButtonPane.setOnMouseReleased(e -> {
if(mask) {
viewPasswordButtonPane.getStyleClass().remove("view-password-button");
viewPasswordButtonPane.getStyleClass().addAll("hide-password-button");
mask = false;
} else {
viewPasswordButtonPane.getStyleClass().remove("hide-password-button");
viewPasswordButtonPane.getStyleClass().addAll("view-password-button");
mask = true;
}
textField.setText(textField.getText());
textField.end();
});
textField.rightProperty().set(viewPasswordButtonPane);
final FadeTransition fader = new FadeTransition(FADE_DURATION, viewPasswordButtonPane);
fader.setCycleCount(1);
textField.textProperty().addListener(new InvalidationListener() {
@Override
public void invalidated(Observable arg0) {
String text = textField.getText();
boolean isTextEmpty = text == null || text.isEmpty();
boolean isButtonVisible = fader.getNode().getOpacity() > 0;
if (isTextEmpty && isButtonVisible) {
setButtonVisible(false);
} else if (!isTextEmpty && !isButtonVisible) {
setButtonVisible(true);
}
}
private void setButtonVisible( boolean visible ) {
fader.setFromValue(visible? 0.0: 1.0);
fader.setToValue(visible? 1.0: 0.0);
fader.play();
}
});
}
@Override
protected String maskText(String txt) {
if(getSkinnable() instanceof PasswordField && mask) {
int n = txt.length();
StringBuilder passwordBuilder = new StringBuilder(n);
for (int i = 0; i < n; i++) {
passwordBuilder.append(BULLET);
}
return passwordBuilder.toString();
} else {
return txt;
}
}
}
@@ -42,9 +42,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new SpecterDesktop(), new Sparrow());
exporters = List.of(new Electrum(), new Descriptor(), new SpecterDesktop(), new Sparrow());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
exporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -54,7 +54,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
importAccordion.getPanes().add(importPane);
}
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
List<WalletImport> walletImporters = List.of(new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
@@ -0,0 +1,65 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
public class WalletLabelDialog extends Dialog<String> {
private final CustomTextField label;
public WalletLabelDialog(String initialName) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Account Name");
dialogPane.setHeaderText("Enter a name for this account:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(400);
dialogPane.setPrefHeight(200);
AppServices.moveToActiveWindowScreen(this);
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
wallet.setFontSize(50);
dialogPane.setGraphic(wallet);
final VBox content = new VBox(20);
label = (CustomTextField) TextFields.createClearableTextField();
label.setText(initialName);
label.setTextFormatter(new TextFormatter<>((change) -> {
change.setText(change.getText().replaceAll("[\\\\/:*?\"<>|]", "_"));
return change;
}));
content.getChildren().add(label);
dialogPane.setContent(content);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(label, Validator.combine(
Validator.createEmptyValidator("Account name is required")
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename Account", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button)dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0, label.textProperty());
okButton.disableProperty().bind(isInvalid);
label.setPromptText("Account Name");
Platform.runLater(label::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? label.getText() : null);
}
}
@@ -8,7 +8,6 @@ import javafx.beans.binding.BooleanBinding;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -31,8 +30,8 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
public WalletPasswordDialog(String walletName, PasswordRequirement requirement, boolean suggestChangePassword) {
this.requirement = requirement;
this.password = (CustomPasswordField)TextFields.createClearablePasswordField();
this.passwordConfirm = (CustomPasswordField)TextFields.createClearablePasswordField();
this.password = new ViewPasswordField();
this.passwordConfirm = new ViewPasswordField();
this.backupExisting = new CheckBox("Backup existing wallet first");
this.changePassword = new CheckBox("Change password");
this.deleteBackups = new CheckBox("Delete any backups");
@@ -15,9 +15,12 @@ import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import java.awt.*;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class WebcamService extends ScheduledService<Image> {
@@ -89,16 +92,20 @@ public class WebcamService extends ScheduledService<Image> {
opening.set(false);
}
BufferedImage bimg = cam.getImage();
if(bimg == null) {
BufferedImage originalImage = cam.getImage();
if(originalImage == null) {
return null;
}
Image image = SwingFXUtils.toFXImage(bimg, null);
CroppedDimension cropped = getCroppedDimension(originalImage);
BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length);
BufferedImage framedImage = getFramedImage(originalImage, cropped);
Image image = SwingFXUtils.toFXImage(framedImage, null);
updateValue(image);
if(System.currentTimeMillis() > (lastQrSampleTime + QR_SAMPLE_PERIOD_MILLIS)) {
readQR(bimg);
readQR(originalImage, croppedImage);
lastQrSampleTime = System.currentTimeMillis();
}
@@ -125,18 +132,49 @@ public class WebcamService extends ScheduledService<Image> {
return super.cancel();
}
private void readQR(BufferedImage bufferedImage) {
private void readQR(BufferedImage wideImage, BufferedImage croppedImage) {
Result result = readQR(wideImage);
if(result == null) {
result = readQR(croppedImage);
}
if(result != null) {
resultProperty.set(result);
}
}
private Result readQR(BufferedImage bufferedImage) {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
Result result = qrReader.decode(bitmap);
resultProperty.set(result);
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
return null;
}
}
private BufferedImage getFramedImage(BufferedImage image, CroppedDimension cropped) {
BufferedImage clone = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = (Graphics2D)clone.getGraphics();
g2d.drawImage(image, 0, 0, null);
float[] dash1 = {10.0f};
g2d.setColor(Color.BLACK);
g2d.setStroke(new BasicStroke(resolution == WebcamResolution.HD ? 3.0f : 1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, dash1, 0.0f));
g2d.draw(new RoundRectangle2D.Double(cropped.x, cropped.y, cropped.length, cropped.length, 10, 10));
g2d.dispose();
return clone;
}
private CroppedDimension getCroppedDimension(BufferedImage bufferedImage) {
int dimension = Math.min(bufferedImage.getWidth(), bufferedImage.getHeight());
int squareSize = dimension / 2;
int x = (bufferedImage.getWidth() - squareSize) / 2;
int y = (bufferedImage.getHeight() - squareSize) / 2;
return new CroppedDimension(x, y, squareSize);
}
public Result getResult() {
return resultProperty.get();
}
@@ -172,4 +210,16 @@ public class WebcamService extends ScheduledService<Image> {
public BooleanProperty openingProperty() {
return opening;
}
private static class CroppedDimension {
public int x;
public int y;
public int length;
public CroppedDimension(int x, int y, int length) {
this.x = x;
this.y = y;
this.length = length;
}
}
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import java.util.Map;
public class KeystoresDiscoveredEvent {
private final Map<StandardAccount, Keystore> discoveredKeystores;
public KeystoresDiscoveredEvent(Map<StandardAccount, Keystore> discoveredKeystores) {
this.discoveredKeystores = discoveredKeystores;
}
public Map<StandardAccount, Keystore> getDiscoveredKeystores() {
return discoveredKeystores;
}
}
@@ -7,7 +7,9 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.TransactionHashIndexEntry;
import java.util.ArrayList;
import java.util.List;
@@ -60,12 +62,21 @@ public class NewWalletTransactionsEvent {
return String.format(Locale.ENGLISH, "%,d", value) + " sats";
}
public List<BlockTransaction> getWhirlpoolMixTransactions() {
public List<BlockTransaction> getUnspentConfirmingWhirlpoolMixTransactions() {
List<BlockTransaction> mixTransactions = new ArrayList<>();
if(wallet.isWhirlpoolMixWallet()) {
return transactionEntries.stream().filter(txEntry -> txEntry.getValue() == 0).map(TransactionEntry::getBlockTransaction).collect(Collectors.toList());
return transactionEntries.stream()
.filter(txEntry -> txEntry.getValue() == 0 && txEntry.isConfirming() && !allOutputsSpent(txEntry))
.map(TransactionEntry::getBlockTransaction).collect(Collectors.toList());
}
return mixTransactions;
}
private boolean allOutputsSpent(TransactionEntry txEntry) {
return txEntry.getChildren().stream()
.map(refEntry -> ((TransactionHashIndexEntry)refEntry))
.filter(txRefEntry -> txRefEntry.getType() == HashIndexEntry.Type.OUTPUT)
.allMatch(txRefEntry -> txRefEntry.getHashIndex().isSpent());
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class SorobanInitiatedEvent {
private Wallet wallet;
public SorobanInitiatedEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -11,6 +11,7 @@ public class SpendUtxoEvent {
private final Wallet wallet;
private final List<BlockTransactionHashIndex> utxos;
private final List<Payment> payments;
private final List<byte[]> opReturns;
private final Long fee;
private final boolean includeSpentMempoolOutputs;
private final Pool pool;
@@ -23,15 +24,17 @@ public class SpendUtxoEvent {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.opReturns = null;
this.fee = fee;
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
this.pool = null;
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, Pool pool) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, Pool pool) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.opReturns = opReturns;
this.fee = fee;
this.includeSpentMempoolOutputs = false;
this.pool = pool;
@@ -49,6 +52,10 @@ public class SpendUtxoEvent {
return payments;
}
public List<byte[]> getOpReturns() {
return opReturns;
}
public Long getFee() {
return fee;
}
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
/**
* This event is posted if the wallet's gap limit has changed, and triggers a history fetch for the new nodes.
*
*/
public class WalletGapLimitChangedEvent extends WalletChangedEvent {
public WalletGapLimitChangedEvent(Wallet wallet) {
super(wallet);
}
public int getGapLimit() {
return getWallet().getGapLimit();
}
}
@@ -3,15 +3,17 @@ package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
public class WalletHistoryStartedEvent extends WalletHistoryStatusEvent {
private final WalletNode walletNode;
import java.util.Set;
public WalletHistoryStartedEvent(Wallet wallet, WalletNode walletNode) {
public class WalletHistoryStartedEvent extends WalletHistoryStatusEvent {
private final Set<WalletNode> walletNodes;
public WalletHistoryStartedEvent(Wallet wallet, Set<WalletNode> walletNodes) {
super(wallet, true);
this.walletNode = walletNode;
this.walletNodes = walletNodes;
}
public WalletNode getWalletNode() {
return walletNode;
public Set<WalletNode> getWalletNodes() {
return walletNodes;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletLabelChangedEvent extends WalletChangedEvent {
public WalletLabelChangedEvent(Wallet wallet) {
super(wallet);
}
public String getLabel() {
return getWallet().getLabel();
}
}
@@ -56,6 +56,7 @@ public class FontAwesome5 extends GlyphFont {
QUESTION_CIRCLE('\uf059'),
RANDOM('\uf074'),
REPLY_ALL('\uf122'),
ROBOT('\uf544'),
SATELLITE_DISH('\uf7c0'),
SD_CARD('\uf7c2'),
SEARCH('\uf002'),
@@ -70,7 +71,10 @@ public class FontAwesome5 extends GlyphFont {
TOGGLE_ON('\uf205'),
TOOLS('\uf7d9'),
UNDO('\uf0e2'),
USER('\uf007'),
USER_FRIENDS('\uf500'),
USER_PLUS('\uf234'),
USER_SLASH('\uf506'),
WALLET('\uf555'),
WEIGHT('\uf496');
@@ -85,7 +85,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB feature on your Coldcard.";
return "Import file created by using the Settings > Multisig Wallets > Export XPUB > 0 feature on your Coldcard.";
}
@Override
@@ -29,7 +29,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON > 0 feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
}
@Override
@@ -12,12 +12,12 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Currency;
import java.util.List;
import java.util.Random;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.net.PagedBatchRequestBuilder.DEFAULT_PAGE_SIZE;
import static com.sparrowwallet.sparrow.net.TcpTransport.DEFAULT_MAX_TIMEOUT;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
@@ -40,8 +40,7 @@ public class Config {
private boolean openWalletsInNewWindows = false;
private boolean hideEmptyUsedAddresses = false;
private boolean showTransactionHex = true;
private boolean showLoadingLog = false;
private boolean showUtxosChart = true;
private boolean showLoadingLog = true;
private boolean preventSleep = false;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
@@ -51,15 +50,19 @@ public class Config {
private ServerType serverType;
private String publicElectrumServer;
private String coreServer;
private List<String> recentCoreServers;
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private Boolean coreMultiWallet;
private String coreWallet;
private String electrumServer;
private List<String> recentElectrumServers;
private File electrumServerCert;
private boolean useProxy;
private String proxyServer;
private int maxServerTimeout = DEFAULT_MAX_TIMEOUT;
private int batchPageSize = DEFAULT_PAGE_SIZE;
private boolean usePayNym;
private boolean sameAppMixing;
private Double appWidth;
private Double appHeight;
@@ -271,15 +274,6 @@ public class Config {
flush();
}
public boolean isShowUtxosChart() {
return showUtxosChart;
}
public void setShowUtxosChart(boolean showUtxosChart) {
this.showUtxosChart = showUtxosChart;
flush();
}
public boolean isPreventSleep() {
return preventSleep;
}
@@ -385,8 +379,10 @@ public class Config {
}
public void changePublicServer() {
List<String> otherServers = Arrays.stream(PublicElectrumServer.values()).map(PublicElectrumServer::getUrl).filter(url -> !url.equals(getPublicElectrumServer())).collect(Collectors.toList());
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
List<String> otherServers = PublicElectrumServer.getServers().stream().map(PublicElectrumServer::getUrl).filter(url -> !url.equals(getPublicElectrumServer())).collect(Collectors.toList());
if(!otherServers.isEmpty()) {
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
}
}
public String getCoreServer() {
@@ -398,6 +394,23 @@ public class Config {
flush();
}
public List<String> getRecentCoreServers() {
return recentCoreServers;
}
public void addRecentCoreServer(String coreServer) {
if(recentCoreServers == null) {
recentCoreServers = new ArrayList<>();
}
if(!recentCoreServers.contains(coreServer)) {
recentCoreServers.stream().filter(url -> Objects.equals(Protocol.getHost(url), Protocol.getHost(coreServer)))
.findFirst().ifPresent(existingUrl -> recentCoreServers.remove(existingUrl));
recentCoreServers.add(coreServer);
flush();
}
}
public CoreAuthType getCoreAuthType() {
return coreAuthType;
}
@@ -434,6 +447,31 @@ public class Config {
flush();
}
public List<String> getRecentElectrumServers() {
return recentElectrumServers;
}
public void addRecentServer() {
if(serverType == ServerType.BITCOIN_CORE && coreServer != null) {
addRecentCoreServer(coreServer);
} else if(serverType == ServerType.ELECTRUM_SERVER && electrumServer != null) {
addRecentElectrumServer(electrumServer);
}
}
public void addRecentElectrumServer(String electrumServer) {
if(recentElectrumServers == null) {
recentElectrumServers = new ArrayList<>();
}
if(!recentElectrumServers.contains(electrumServer)) {
recentElectrumServers.stream().filter(url -> Objects.equals(Protocol.getHost(url), Protocol.getHost(electrumServer)))
.findFirst().ifPresent(existingUrl -> recentElectrumServers.remove(existingUrl));
recentElectrumServers.add(electrumServer);
flush();
}
}
public File getElectrumServerCert() {
return electrumServerCert;
}
@@ -461,6 +499,32 @@ public class Config {
flush();
}
public int getMaxServerTimeout() {
return maxServerTimeout;
}
public int getBatchPageSize() {
return batchPageSize;
}
public boolean isUsePayNym() {
return usePayNym;
}
public void setUsePayNym(boolean usePayNym) {
this.usePayNym = usePayNym;
flush();
}
public boolean isSameAppMixing() {
return sameAppMixing;
}
public void setSameAppMixing(boolean sameAppMixing) {
this.sameAppMixing = sameAppMixing;
flush();
}
public Double getAppWidth() {
return appWidth;
}
@@ -0,0 +1,81 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
public class Descriptor implements WalletImport, WalletExport {
@Override
public String getName() {
return "Output Descriptor";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.BITCOIN_CORE;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null);
String outputDescriptorString = outputDescriptor.toString(true);
outputStream.write(outputDescriptorString.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch(Exception e) {
throw new ExportException("Error writing output descriptor", e);
}
}
@Override
public String getWalletExportDescription() {
return "The output descriptor is a standardized description of the wallet compatible with Bitcoin Core, and can be used to create a watch-only copy using the Edit button on the Settings tab of a new Sparrow wallet.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "txt";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public String getWalletImportDescription() {
return "Import a file containing the output descriptor of a wallet. The output descriptor is a standardized description of the wallet compatible with Bitcoin Core.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
String outputDescriptor = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor.trim());
return descriptor.toWallet();
} catch(Exception e) {
throw new ImportException("Error importing output descriptor", e);
}
}
@Override
public boolean isWalletImportScannable() {
return false;
}
}
@@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
@@ -38,6 +39,7 @@ public class Hwi {
private static boolean isPromptActive = false;
public List<Device> enumerate(String passphrase) throws ImportException {
String output = null;
try {
List<String> command;
if(passphrase != null && !passphrase.isEmpty()) {
@@ -47,7 +49,7 @@ public class Hwi {
}
isPromptActive = true;
String output = execute(command);
output = execute(command);
Device[] devices = getGson().fromJson(output, Device[].class);
if(devices == null) {
throw new ImportException("Error scanning, check devices are ready");
@@ -55,7 +57,19 @@ public class Hwi {
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
log.error("Error executing " + HWI_VERSION_DIR, e);
throw new ImportException(e);
throw new ImportException("Error executing HWI", e);
} catch(Exception e) {
if(output != null) {
try {
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
JsonElement error = result.get("error");
throw new ImportException(error.getAsString());
} catch(Exception ex) {
log.error("Error parsing JSON: " + output, e);
throw new ImportException("Error parsing JSON: " + output, e);
}
}
throw e;
} finally {
isPromptActive = false;
}
@@ -91,6 +105,15 @@ public class Hwi {
}
}
public Map<StandardAccount, String> getXpubs(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) throws ImportException {
Map<StandardAccount, String> accountXpubs = new LinkedHashMap<>();
for(Map.Entry<StandardAccount, String> entry : accountDerivationPaths.entrySet()) {
accountXpubs.put(entry.getKey(), getXpub(device, passphrase, entry.getValue()));
}
return accountXpubs;
}
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
try {
String output;
@@ -210,7 +233,7 @@ public class Hwi {
} catch(IOException e) {
throw new SignTransactionException("Could not sign PSBT", e);
} catch(PSBTParseException e) {
throw new SignTransactionException("Could not parsed signed PSBT", e);
throw new SignTransactionException("Could not parse signed PSBT", e);
} finally {
isPromptActive = false;
}
@@ -580,6 +603,28 @@ public class Hwi {
}
}
public static class GetXpubsService extends Service<Map<StandardAccount, String>> {
private final Device device;
private final String passphrase;
private final Map<StandardAccount, String> accountDerivationPaths;
public GetXpubsService(Device device, String passphrase, Map<StandardAccount, String> accountDerivationPaths) {
this.device = device;
this.passphrase = passphrase;
this.accountDerivationPaths = accountDerivationPaths;
}
@Override
protected Task<Map<StandardAccount, String>> createTask() {
return new Task<>() {
protected Map<StandardAccount, String> call() throws ImportException {
Hwi hwi = new Hwi();
return hwi.getXpubs(device, passphrase, accountDerivationPaths);
}
};
}
}
public static class SignPSBTService extends Service<PSBT> {
private final Device device;
private final String passphrase;
@@ -101,9 +101,12 @@ public class Sparrow implements WalletImport, WalletExport {
java.nio.file.Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
PersistenceType persistenceType = Storage.detectPersistenceType(tempFile);
persistenceType = (persistenceType == null ? PersistenceType.JSON : persistenceType);
File tempTypedFile = new File(tempFile.getParentFile(), tempFile.getName() + "." + persistenceType.getExtension());
tempFile.renameTo(tempTypedFile);
tempFile = tempTypedFile;
if(persistenceType != PersistenceType.JSON || !isEncrypted(tempFile)) {
File tempTypedFile = new File(tempFile.getParentFile(), tempFile.getName() + "." + persistenceType.getExtension());
tempFile.renameTo(tempTypedFile);
tempFile = tempTypedFile;
}
storage = new Storage(persistenceType, tempFile);
if(!isEncrypted(tempFile)) {
wallet = storage.loadUnencryptedWallet().getWallet();
@@ -13,12 +13,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
@@ -172,24 +174,27 @@ public class Storage {
deleteBackups(null);
}
public void deleteTempBackups() {
public void deleteTempBackups(boolean forceSave) {
File[] backups = getBackups(Storage.TEMP_BACKUP_PREFIX);
if(backups.length > 0) {
try {
Date date = BACKUP_DATE_FORMAT.parse(getBackupDate(backups[0].getName()));
ProcessHandle.Info processInfo = ProcessHandle.current().info();
if(processInfo.startInstant().isPresent() && processInfo.startInstant().get().isAfter(date.toInstant())) {
File permanent = new File(backups[0].getParent(), backups[0].getName().substring(Storage.TEMP_BACKUP_PREFIX.length() + 1));
backups[0].renameTo(permanent);
}
} catch(Exception e) {
log.error("Error copying temporary to permanent backup", e);
}
if(backups.length > 0 && (forceSave || hasStartedSince(backups[0]))) {
File permanent = new File(backups[0].getParent(), backups[0].getName().substring(Storage.TEMP_BACKUP_PREFIX.length() + 1));
backups[0].renameTo(permanent);
}
deleteBackups(Storage.TEMP_BACKUP_PREFIX);
}
private boolean hasStartedSince(File lastBackup) {
try {
Date date = BACKUP_DATE_FORMAT.parse(getBackupDate(lastBackup.getName()));
ProcessHandle.Info processInfo = ProcessHandle.current().info();
return (processInfo.startInstant().isPresent() && processInfo.startInstant().get().isAfter(date.toInstant()));
} catch(Exception e) {
log.error("Error parsing date for backup file " + lastBackup.getName(), e);
return false;
}
}
private void deleteBackups(String prefix) {
File[] backups = getBackups(prefix);
for(File backup : backups) {
@@ -373,7 +378,7 @@ public class Storage {
}
public static PersistenceType detectPersistenceType(File walletFile) {
try(Reader reader = new FileReader(walletFile)) {
try(Reader reader = new FileReader(walletFile, StandardCharsets.UTF_8)) {
int firstChar = reader.read();
if(firstChar == 'U' || firstChar == '{') {
@@ -10,6 +10,7 @@ import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public interface BlockTransactionDao {
@@ -35,7 +36,8 @@ public interface BlockTransactionDao {
void clear(long wallet);
default void addBlockTransactions(Wallet wallet) {
for(Map.Entry<Sha256Hash, BlockTransaction> blkTxEntry : wallet.getTransactions().entrySet()) {
Map<Sha256Hash, BlockTransaction> walletTransactions = new HashMap<>(wallet.getTransactions());
for(Map.Entry<Sha256Hash, BlockTransaction> blkTxEntry : walletTransactions.entrySet()) {
blkTxEntry.getValue().setId(null);
addOrUpdate(wallet, blkTxEntry.getKey(), blkTxEntry.getValue());
}
@@ -236,10 +236,18 @@ public class DbPersistence implements Persistence {
}
}
if(dirtyPersistables.label != null) {
walletDao.updateLabel(wallet.getId(), dirtyPersistables.label.length() > 255 ? dirtyPersistables.label.substring(0, 255) : dirtyPersistables.label);
}
if(dirtyPersistables.blockHeight != null) {
walletDao.updateStoredBlockHeight(wallet.getId(), dirtyPersistables.blockHeight);
}
if(dirtyPersistables.gapLimit != null) {
walletDao.updateGapLimit(wallet.getId(), dirtyPersistables.gapLimit);
}
if(!dirtyPersistables.labelEntries.isEmpty()) {
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
@@ -637,6 +645,13 @@ public class DbPersistence implements Persistence {
}
}
@Subscribe
public void walletLabelChanged(WalletLabelChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).label = event.getLabel();
}
}
@Subscribe
public void walletBlockHeightChanged(WalletBlockHeightChangedEvent event) {
if(persistsFor(event.getWallet())) {
@@ -644,6 +659,13 @@ public class DbPersistence implements Persistence {
}
}
@Subscribe
public void walletGapLimitChanged(WalletGapLimitChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).gapLimit = event.getGapLimit();
}
}
@Subscribe
public void walletEntryLabelsChanged(WalletEntryLabelsChangedEvent event) {
if(persistsFor(event.getWallet())) {
@@ -690,7 +712,9 @@ public class DbPersistence implements Persistence {
private static class DirtyPersistables {
public boolean clearHistory;
public final List<WalletNode> historyNodes = new ArrayList<>();
public String label;
public Integer blockHeight = null;
public Integer gapLimit = null;
public final List<Entry> labelEntries = new ArrayList<>();
public final List<BlockTransactionHashIndex> utxoStatuses = new ArrayList<>();
public boolean mixConfig;
@@ -703,7 +727,9 @@ public class DbPersistence implements Persistence {
return "Dirty Persistables" +
"\nClear history:" + clearHistory +
"\nNodes:" + historyNodes +
"\nLabel:" + label +
"\nBlockHeight:" + blockHeight +
"\nGap limit:" + gapLimit +
"\nTx labels:" + labelEntries.stream().filter(entry -> entry instanceof TransactionEntry).map(entry -> ((TransactionEntry)entry).getBlockTransaction().getHash().toString()).collect(Collectors.toList()) +
"\nAddress labels:" + labelEntries.stream().filter(entry -> entry instanceof NodeEntry).map(entry -> ((NodeEntry)entry).getNode().toString() + " " + entry.getLabel()).collect(Collectors.toList()) +
"\nUTXO labels:" + labelEntries.stream().filter(entry -> entry instanceof HashIndexEntry).map(entry -> ((HashIndexEntry)entry).getHashIndex().toString()).collect(Collectors.toList()) +
@@ -8,16 +8,16 @@ import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
public interface MixConfigDao {
@SqlQuery("select id, scode, mixOnStartup, mixToWalletFile, mixToWalletName, minMixes, receiveIndex, changeIndex from mixConfig where wallet = ?")
@SqlQuery("select id, scode, mixOnStartup, indexRange, mixToWalletFile, mixToWalletName, minMixes, receiveIndex, changeIndex from mixConfig where wallet = ?")
@RegisterRowMapper(MixConfigMapper.class)
MixConfig getForWalletId(Long id);
@SqlUpdate("insert into mixConfig (scode, mixOnStartup, mixToWalletFile, mixToWalletName, minMixes, receiveIndex, changeIndex, wallet) values (?, ?, ?, ?, ?, ?, ?, ?)")
@SqlUpdate("insert into mixConfig (scode, mixOnStartup, indexRange, mixToWalletFile, mixToWalletName, minMixes, receiveIndex, changeIndex, wallet) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertMixConfig(String scode, Boolean mixOnStartup, String mixToWalletFile, String mixToWalletName, Integer minMixes, int receiveIndex, int changeIndex, long wallet);
long insertMixConfig(String scode, Boolean mixOnStartup, String indexRange, String mixToWalletFile, String mixToWalletName, Integer minMixes, int receiveIndex, int changeIndex, long wallet);
@SqlUpdate("update mixConfig set scode = ?, mixOnStartup = ?, mixToWalletFile = ?, mixToWalletName = ?, minMixes = ?, receiveIndex = ?, changeIndex = ?, wallet = ? where id = ?")
void updateMixConfig(String scode, Boolean mixOnStartup, String mixToWalletFile, String mixToWalletName, Integer minMixes, int receiveIndex, int changeIndex, long wallet, long id);
@SqlUpdate("update mixConfig set scode = ?, mixOnStartup = ?, indexRange = ?, mixToWalletFile = ?, mixToWalletName = ?, minMixes = ?, receiveIndex = ?, changeIndex = ?, wallet = ? where id = ?")
void updateMixConfig(String scode, Boolean mixOnStartup, String indexRange, String mixToWalletFile, String mixToWalletName, Integer minMixes, int receiveIndex, int changeIndex, long wallet, long id);
default void addMixConfig(Wallet wallet) {
if(wallet.getMixConfig() != null) {
@@ -32,10 +32,10 @@ public interface MixConfigDao {
}
if(mixConfig.getId() == null) {
long id = insertMixConfig(mixConfig.getScode(), mixConfig.getMixOnStartup(), mixToWalletFile, mixConfig.getMixToWalletName(), mixConfig.getMinMixes(), mixConfig.getReceiveIndex(), mixConfig.getChangeIndex(), wallet.getId());
long id = insertMixConfig(mixConfig.getScode(), mixConfig.getMixOnStartup(), mixConfig.getIndexRange(), mixToWalletFile, mixConfig.getMixToWalletName(), mixConfig.getMinMixes(), mixConfig.getReceiveIndex(), mixConfig.getChangeIndex(), wallet.getId());
mixConfig.setId(id);
} else {
updateMixConfig(mixConfig.getScode(), mixConfig.getMixOnStartup(), mixToWalletFile, mixConfig.getMixToWalletName(), mixConfig.getMinMixes(), mixConfig.getReceiveIndex(), mixConfig.getChangeIndex(), wallet.getId(), mixConfig.getId());
updateMixConfig(mixConfig.getScode(), mixConfig.getMixOnStartup(), mixConfig.getIndexRange(), mixToWalletFile, mixConfig.getMixToWalletName(), mixConfig.getMinMixes(), mixConfig.getReceiveIndex(), mixConfig.getChangeIndex(), wallet.getId(), mixConfig.getId());
}
}
}
@@ -18,6 +18,7 @@ public class MixConfigMapper implements RowMapper<MixConfig> {
mixOnStartup = null;
}
String indexRange = rs.getString("indexRange");
String mixToWalletFile = rs.getString("mixToWalletFile");
String mixToWalletName = rs.getString("mixToWalletName");
@@ -26,7 +27,7 @@ public class MixConfigMapper implements RowMapper<MixConfig> {
minMixes = null;
}
MixConfig mixConfig = new MixConfig(scode, mixOnStartup, mixToWalletFile == null ? null : new File(mixToWalletFile), mixToWalletName, minMixes, rs.getInt("receiveIndex"), rs.getInt("changeIndex"));
MixConfig mixConfig = new MixConfig(scode, mixOnStartup, indexRange, mixToWalletFile == null ? null : new File(mixToWalletFile), mixToWalletName, minMixes, rs.getInt("receiveIndex"), rs.getInt("changeIndex"));
mixConfig.setId(rs.getLong("id"));
return mixConfig;
}
@@ -36,25 +36,31 @@ public interface WalletDao {
@CreateSqlObject
UtxoMixDataDao createUtxoMixDataDao();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id")
@SqlQuery("select wallet.id, wallet.name, wallet.label, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadAllWallets();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id = 1")
@SqlQuery("select wallet.id, wallet.name, wallet.label, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id = 1")
@RegisterRowMapper(WalletMapper.class)
Wallet loadMainWallet();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id != 1")
@SqlQuery("select wallet.id, wallet.name, wallet.label, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id != 1")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadChildWallets();
@SqlUpdate("insert into wallet (name, network, policyType, scriptType, storedBlockHeight, gapLimit, birthDate, defaultPolicy) values (?, ?, ?, ?, ?, ?, ?, ?)")
@SqlUpdate("insert into wallet (name, label, network, policyType, scriptType, storedBlockHeight, gapLimit, birthDate, defaultPolicy) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insert(String name, int network, int policyType, int scriptType, Integer storedBlockHeight, Integer gapLimit, Date birthDate, long defaultPolicy);
long insert(String name, String label, int network, int policyType, int scriptType, Integer storedBlockHeight, Integer gapLimit, Date birthDate, long defaultPolicy);
@SqlUpdate("update wallet set label = :label where id = :id")
void updateLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("update wallet set storedBlockHeight = :blockHeight where id = :id")
void updateStoredBlockHeight(@Bind("id") long id, @Bind("blockHeight") Integer blockHeight);
@SqlUpdate("update wallet set gapLimit = :gapLimit where id = :id")
void updateGapLimit(@Bind("id") long id, @Bind("gapLimit") Integer gapLimit);
@SqlUpdate("set schema ?")
int setSchema(String schema);
@@ -105,7 +111,7 @@ public interface WalletDao {
setSchema(schema);
createPolicyDao().addPolicy(wallet.getDefaultPolicy());
long id = insert(truncate(wallet.getName()), wallet.getNetwork().ordinal(), wallet.getPolicyType().ordinal(), wallet.getScriptType().ordinal(), wallet.getStoredBlockHeight(), wallet.gapLimit(), wallet.getBirthDate(), wallet.getDefaultPolicy().getId());
long id = insert(truncate(wallet.getName()), truncate(wallet.getLabel()), wallet.getNetwork().ordinal(), wallet.getPolicyType().ordinal(), wallet.getScriptType().ordinal(), wallet.getStoredBlockHeight(), wallet.gapLimit(), wallet.getBirthDate(), wallet.getDefaultPolicy().getId());
wallet.setId(id);
createKeystoreDao().addKeystores(wallet);
@@ -17,6 +17,7 @@ public class WalletMapper implements RowMapper<Wallet> {
public Wallet map(ResultSet rs, StatementContext ctx) throws SQLException {
Wallet wallet = new Wallet(rs.getString("wallet.name"));
wallet.setId(rs.getLong("wallet.id"));
wallet.setLabel(rs.getString("wallet.label"));
wallet.setNetwork(Network.values()[rs.getInt("wallet.network")]);
wallet.setPolicyType(PolicyType.values()[rs.getInt("wallet.policyType")]);
wallet.setScriptType(ScriptType.values()[rs.getInt("wallet.scriptType")]);
@@ -11,6 +11,7 @@ import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jdbi.v3.sqlobject.statement.UseRowReducer;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@@ -60,7 +61,8 @@ public interface WalletNodeDao {
for(WalletNode purposeNode : wallet.getPurposeNodes()) {
long purposeNodeId = insertWalletNode(purposeNode.getDerivationPath(), truncate(purposeNode.getLabel()), wallet.getId(), null);
purposeNode.setId(purposeNodeId);
for(WalletNode addressNode : purposeNode.getChildren()) {
List<WalletNode> childNodes = new ArrayList<>(purposeNode.getChildren());
for(WalletNode addressNode : childNodes) {
long addressNodeId = insertWalletNode(addressNode.getDerivationPath(), truncate(addressNode.getLabel()), wallet.getId(), purposeNodeId);
addressNode.setId(addressNodeId);
addTransactionOutputs(addressNode);
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.net;
public class AllHistoryChangedException extends RuntimeException {
public AllHistoryChangedException() {
}
public AllHistoryChangedException(String message) {
super(message);
}
public AllHistoryChangedException(String message, Throwable cause) {
super(message, cause);
}
public AllHistoryChangedException(Throwable cause) {
super(cause);
}
}
@@ -5,8 +5,6 @@ import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.builder.BatchRequestBuilder;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
@@ -19,10 +17,12 @@ import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.wallet.WalletNode.nodeRangesToString;
public class BatchedElectrumServerRpc implements ElectrumServerRpc {
private static final Logger log = LoggerFactory.getLogger(BatchedElectrumServerRpc.class);
private static final int MAX_RETRIES = 5;
private static final int RETRY_DELAY = 1;
static final int MAX_RETRIES = 5;
static final int RETRY_DELAY = 1;
private final AtomicLong idCounter = new AtomicLong();
@@ -73,19 +73,18 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + getScriptHashesAbbreviation(pathScriptHashes.keySet())));
PagedBatchRequestBuilder<String, ScriptHashTx[]> batchRequest = PagedBatchRequestBuilder.create(transport).keysType(String.class).returnType(ScriptHashTx[].class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + nodeRangesToString(pathScriptHashes.keySet())));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.get_history", pathScriptHashes.get(path));
}
try {
return new RetryLogic<Map<String, ScriptHashTx[]>>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
return batchRequest.execute();
} catch (JsonRpcBatchException e) {
if(failOnError) {
throw new ElectrumServerRpcException("Failed to retrieve transaction history for paths: " + getScriptHashesAbbreviation((Collection<String>)e.getErrors().keySet()), e);
throw new ElectrumServerRpcException("Failed to retrieve transaction history for paths: " + nodeRangesToString((Collection<String>)e.getErrors().keySet()), e);
}
Map<String, ScriptHashTx[]> result = (Map<String, ScriptHashTx[]>)e.getSuccesses();
@@ -95,25 +94,24 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
return result;
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to retrieve transaction history for paths: " + getScriptHashesAbbreviation(pathScriptHashes.keySet()), e);
throw new ElectrumServerRpcException("Failed to retrieve transaction history for paths: " + nodeRangesToString(pathScriptHashes.keySet()), e);
}
}
@Override
@SuppressWarnings("unchecked")
public Map<String, ScriptHashTx[]> getScriptHashMempool(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
PagedBatchRequestBuilder<String, ScriptHashTx[]> batchRequest = PagedBatchRequestBuilder.create(transport).keysType(String.class).returnType(ScriptHashTx[].class);
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.get_mempool", pathScriptHashes.get(path));
}
try {
return new RetryLogic<Map<String, ScriptHashTx[]>>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
if(failOnError) {
throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for paths: " + getScriptHashesAbbreviation((Collection<String>)e.getErrors().keySet()), e);
throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for paths: " + nodeRangesToString((Collection<String>)e.getErrors().keySet()), e);
}
Map<String, ScriptHashTx[]> result = (Map<String, ScriptHashTx[]>)e.getSuccesses();
@@ -123,36 +121,34 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
return result;
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for paths: " + getScriptHashesAbbreviation(pathScriptHashes.keySet()), e);
throw new ElectrumServerRpcException("Failed to retrieve mempool transactions for paths: " + nodeRangesToString(pathScriptHashes.keySet()), e);
}
}
@Override
@SuppressWarnings("unchecked")
public Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + getScriptHashesAbbreviation(pathScriptHashes.keySet())));
PagedBatchRequestBuilder<String, String> batchRequest = PagedBatchRequestBuilder.create(transport).keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + nodeRangesToString(pathScriptHashes.keySet())));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.subscribe", pathScriptHashes.get(path));
}
try {
return new RetryLogic<Map<String, String>>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
throw new ElectrumServerRpcException("Failed to subscribe to paths: " + getScriptHashesAbbreviation((Collection<String>)e.getErrors().keySet()), e);
throw new ElectrumServerRpcException("Failed to subscribe to paths: " + nodeRangesToString((Collection<String>)e.getErrors().keySet()), e);
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to subscribe to paths: " + getScriptHashesAbbreviation(pathScriptHashes.keySet()), e);
throw new ElectrumServerRpcException("Failed to subscribe to paths: " + nodeRangesToString(pathScriptHashes.keySet()), e);
}
}
@Override
@SuppressWarnings("unchecked")
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<Integer, String> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(String.class);
PagedBatchRequestBuilder<Integer, String> batchRequest = PagedBatchRequestBuilder.create(transport).keysType(Integer.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving " + blockHeights.size() + " block headers"));
for(Integer height : blockHeights) {
@@ -160,7 +156,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
try {
return new RetryLogic<Map<Integer, String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
return (Map<Integer, String>)e.getSuccesses();
} catch(Exception e) {
@@ -171,8 +167,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
@Override
@SuppressWarnings("unchecked")
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
PagedBatchRequestBuilder<String, String> batchRequest = PagedBatchRequestBuilder.create(transport).keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving " + txids.size() + " transactions"));
for(String txid : txids) {
@@ -180,7 +175,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
try {
return new RetryLogic<Map<String, String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
Map<String, String> result = (Map<String, String>)e.getSuccesses();
@@ -276,65 +271,4 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
throw new ElectrumServerRpcException("Error broadcasting transaction", e);
}
}
private static String getScriptHashesAbbreviation(Collection<String> scriptHashes) {
List<String> sortedHashes = new ArrayList<>(scriptHashes);
if(scriptHashes.isEmpty()) {
return "[]";
}
List<List<String>> contiguous = splitToContiguous(sortedHashes);
String abbrev = "[";
for(Iterator<List<String>> iter = contiguous.iterator(); iter.hasNext(); ) {
List<String> range = iter.next();
abbrev += range.get(0);
if(range.size() > 1) {
abbrev += "-" + range.get(range.size() - 1);
}
if(iter.hasNext()) {
abbrev += ", ";
}
}
abbrev += "]";
return abbrev;
}
static List<List<String>> splitToContiguous(List<String> input) {
List<List<String>> result = new ArrayList<>();
int prev = 0;
int keyPurpose = getKeyPurpose(input.get(0));
int index = getIndex(input.get(0));
for (int cur = 0; cur < input.size(); cur++) {
if(getKeyPurpose(input.get(cur)) != keyPurpose || getIndex(input.get(cur)) != index) {
result.add(input.subList(prev, cur));
prev = cur;
}
index = getIndex(input.get(cur)) + 1;
keyPurpose = getKeyPurpose(input.get(cur));
}
result.add(input.subList(prev, input.size()));
return result;
}
private static int getKeyPurpose(String path) {
List<ChildNumber> childNumbers = KeyDerivation.parsePath(path);
if(childNumbers.isEmpty()) {
return -1;
}
return childNumbers.get(0).num();
}
private static int getIndex(String path) {
List<ChildNumber> childNumbers = KeyDerivation.parsePath(path);
if(childNumbers.isEmpty()) {
return -1;
}
return childNumbers.get(childNumbers.size() - 1).num();
}
}
@@ -167,8 +167,14 @@ public class ElectrumServer {
}
public static void addCalculatedScriptHashes(Wallet wallet) {
calculateScriptHashes(wallet, KeyPurpose.RECEIVE).forEach(retrievedScriptHashes::putIfAbsent);
calculateScriptHashes(wallet, KeyPurpose.CHANGE).forEach(retrievedScriptHashes::putIfAbsent);
getCalculatedScriptHashes(wallet).forEach(retrievedScriptHashes::putIfAbsent);
}
private static Map<String, String> getCalculatedScriptHashes(Wallet wallet) {
Map<String, String> storedScriptHashStatuses = new HashMap<>();
storedScriptHashStatuses.putAll(calculateScriptHashes(wallet, KeyPurpose.RECEIVE));
storedScriptHashStatuses.putAll(calculateScriptHashes(wallet, KeyPurpose.CHANGE));
return storedScriptHashStatuses;
}
private static Map<String, String> calculateScriptHashes(Wallet wallet, KeyPurpose keyPurpose) {
@@ -1188,14 +1194,30 @@ public class ElectrumServer {
synchronized(walletSynchronizeLocks.get(wallet)) {
if(isConnected()) {
ElectrumServer electrumServer = new ElectrumServer();
Map<String, String> previousScriptHashes = getCalculatedScriptHashes(wallet);
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
//Add all of the script hashes we have now fetched the history for so we don't need to fetch again until the script hash status changes
for(WalletNode node : (nodes == null ? nodeTransactionMap.keySet() : nodes)) {
Set<WalletNode> updatedNodes = new HashSet<>();
Map<WalletNode, Set<BlockTransactionHashIndex>> walletNodes = wallet.getWalletNodes();
for(WalletNode node : (nodes == null ? walletNodes.keySet() : nodes)) {
String scriptHash = getScriptHash(wallet, node);
retrievedScriptHashes.put(scriptHash, getSubscribedScriptHashStatus(scriptHash));
String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
if(!Objects.equals(subscribedStatus, retrievedScriptHashes.get(scriptHash))) {
updatedNodes.add(node);
}
retrievedScriptHashes.put(scriptHash, subscribedStatus);
}
//If wallet was not empty, check if all used updated nodes have changed history
if(nodes == null && previousScriptHashes.values().stream().anyMatch(Objects::nonNull)) {
if(!updatedNodes.isEmpty() && updatedNodes.equals(walletNodes.entrySet().stream().filter(entry -> !entry.getValue().isEmpty()).map(Map.Entry::getKey).collect(Collectors.toSet()))) {
//All used nodes on a non-empty wallet have changed history. Abort and trigger a full refresh.
log.info("All used nodes on a non-empty wallet have changed history. Triggering a full wallet refresh.");
throw new AllHistoryChangedException();
}
}
//Clear transaction outputs for nodes that have no history - this is useful when a transaction is replaced in the mempool
@@ -1415,4 +1437,48 @@ public class ElectrumServer {
};
}
}
public static class WalletDiscoveryService extends Service<List<StandardAccount>> {
private final Wallet masterWalletCopy;
private final List<StandardAccount> standardAccounts;
private final Map<StandardAccount, Keystore> importedKeystores;
public WalletDiscoveryService(Wallet masterWallet, List<StandardAccount> standardAccounts) {
this.masterWalletCopy = masterWallet.copy();
this.standardAccounts = standardAccounts;
this.importedKeystores = new HashMap<>();
}
public WalletDiscoveryService(Wallet masterWallet, Map<StandardAccount, Keystore> importedKeystores) {
this.masterWalletCopy = masterWallet.copy();
this.standardAccounts = new ArrayList<>(importedKeystores.keySet());
this.importedKeystores = importedKeystores;
}
@Override
protected Task<List<StandardAccount>> createTask() {
return new Task<>() {
protected List<StandardAccount> call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
List<StandardAccount> discoveredAccounts = new ArrayList<>();
for(StandardAccount standardAccount : standardAccounts) {
Wallet wallet = masterWalletCopy.addChildWallet(standardAccount);
if(importedKeystores.containsKey(standardAccount)) {
wallet.getKeystores().clear();
wallet.getKeystores().add(importedKeystores.get(standardAccount));
}
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0);
if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) {
discoveredAccounts.add(standardAccount);
}
}
return discoveredAccounts;
}
};
}
}
}
@@ -35,6 +35,17 @@ public enum FeeRatesSource {
String url = "https://bitcoinfees.earn.com/api/v1/fees/recommended";
return getThreeTierFeeRates(defaultblockTargetFeeRates, url);
}
},
MINIMUM("Minimum (1 sat/vB)") {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
Map<Integer, Double> blockTargetFeeRates = new LinkedHashMap<>();
for(Integer blockTarget : defaultblockTargetFeeRates.keySet()) {
blockTargetFeeRates.put(blockTarget, 1.0);
}
return blockTargetFeeRates;
}
};
private static final Logger log = LoggerFactory.getLogger(FeeRatesSource.class);
@@ -0,0 +1,145 @@
package com.sparrowwallet.sparrow.net;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.Transport;
import com.github.arteam.simplejsonrpc.client.builder.AbstractBuilder;
import com.github.arteam.simplejsonrpc.client.builder.BatchRequestBuilder;
import com.google.common.collect.Lists;
import com.sparrowwallet.sparrow.io.Config;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import static com.sparrowwallet.sparrow.net.BatchedElectrumServerRpc.MAX_RETRIES;
import static com.sparrowwallet.sparrow.net.BatchedElectrumServerRpc.RETRY_DELAY;
public class PagedBatchRequestBuilder<K, V> extends AbstractBuilder {
public static final int DEFAULT_PAGE_SIZE = 500;
@NotNull
private final List<Request> requests;
/**
* Type of request ids
*/
@Nullable
private final Class<K> keysType;
/**
* Expected return type for all requests
* <p/>
* This property works exclusively with {@code returnTypes}. Only one of them should be set.
*/
@Nullable
private final Class<V> returnType;
/**
* Creates a new batch request builder in an initial state
*
* @param transport transport for request performing
* @param mapper mapper for JSON processing
*/
public PagedBatchRequestBuilder(@NotNull Transport transport, @NotNull ObjectMapper mapper) {
this(transport, mapper, new ArrayList<Request>(), null, null);
}
public PagedBatchRequestBuilder(@NotNull Transport transport, @NotNull ObjectMapper mapper,
@NotNull List<Request> requests,
@Nullable Class<K> keysType, @Nullable Class<V> returnType) {
super(transport, mapper);
this.requests = requests;
this.keysType = keysType;
this.returnType = returnType;
}
/**
* Adds a new request without specifying a return type
*
* @param id request id as a text value
* @param method request method
* @param param request param
* @return the current builder
*/
@NotNull
public PagedBatchRequestBuilder<K, V> add(Object id, @NotNull String method, @NotNull Object param) {
requests.add(new Request(id, method, param));
return this;
}
/**
* Sets type of request keys.
* The purpose of this method is providing static and runtime type safety of processing of batch responses
*
* @param keysClass type of keys
* @param <NK> type of keys
* @return a new builder
*/
public <NK> PagedBatchRequestBuilder<NK, V> keysType(@NotNull Class<NK> keysClass) {
return new PagedBatchRequestBuilder<NK, V>(transport, mapper, requests, keysClass, returnType);
}
/**
* Sets an expected response type of requests.
* This method is preferred when requests have the same response type.
*
* @param valuesClass expected requests return type
* @param <NV> expected requests return type
* @return a new builder
*/
public <NV> PagedBatchRequestBuilder<K, NV> returnType(@NotNull Class<NV> valuesClass) {
return new PagedBatchRequestBuilder<K, NV>(transport, mapper, requests, keysType, valuesClass);
}
/**
* Validates, executes the request and process response
*
* @return map of responses by request ids
*/
@NotNull
public Map<K, V> execute() throws Exception {
Map<K, V> allResults = new HashMap<>();
JsonRpcClient client = new JsonRpcClient(transport);
List<List<Request>> pages = Lists.partition(requests, getPageSize());
for(List<Request> page : pages) {
BatchRequestBuilder<K, V> batchRequest = client.createBatchRequest().keysType(keysType).returnType(returnType);
for(Request request : page) {
if(request.id instanceof String strReq) {
batchRequest.add(strReq, request.method, request.param);
} else if(request.id instanceof Integer intReq) {
batchRequest.add(intReq, request.method, request.param);
} else {
throw new IllegalArgumentException("Id of class " + request.id.getClass().getName() + " not supported");
}
}
Map<K, V> pageResult = new RetryLogic<Map<K, V>>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
allResults.putAll(pageResult);
}
return allResults;
}
private int getPageSize() {
int pageSize = Config.get().getBatchPageSize();
if(pageSize < 1) {
pageSize = DEFAULT_PAGE_SIZE;
}
return pageSize;
}
/**
* Creates a builder of a JSON-RPC batch request in initial state
*
* @return batch request builder
*/
@NotNull
public static PagedBatchRequestBuilder<?, ?> create(Transport transport) {
return new PagedBatchRequestBuilder<Object, Object>(transport, new ObjectMapper());
}
private static record Request(Object id, String method, Object param) {}
}
@@ -144,4 +144,13 @@ public enum Protocol {
return null;
}
public static String getHost(String url) {
Protocol protocol = getProtocol(url);
if(protocol != null) {
return protocol.getServerHostAndPort(url).getHost();
}
return null;
}
}
@@ -1,19 +1,30 @@
package com.sparrowwallet.sparrow.net;
public enum PublicElectrumServer {
BLOCKSTREAM_INFO("blockstream.info", "ssl://blockstream.info:700"),
ELECTRUM_BLOCKSTREAM_INFO("electrum.blockstream.info", "ssl://electrum.blockstream.info:50002"),
LUKECHILDS_CO("bitcoin.lukechilds.co", "ssl://bitcoin.lukechilds.co:50002"),
EMZY_DE("electrum.emzy.de", "ssl://electrum.emzy.de:50002"),
BITAROO_NET("electrum.bitaroo.net", "ssl://electrum.bitaroo.net:50002");
import com.sparrowwallet.drongo.Network;
PublicElectrumServer(String name, String url) {
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public enum PublicElectrumServer {
BLOCKSTREAM_INFO("blockstream.info", "ssl://blockstream.info:700", Network.MAINNET),
ELECTRUM_BLOCKSTREAM_INFO("electrum.blockstream.info", "ssl://electrum.blockstream.info:50002", Network.MAINNET),
LUKECHILDS_CO("bitcoin.lukechilds.co", "ssl://bitcoin.lukechilds.co:50002", Network.MAINNET),
EMZY_DE("electrum.emzy.de", "ssl://electrum.emzy.de:50002", Network.MAINNET),
BITAROO_NET("electrum.bitaroo.net", "ssl://electrum.bitaroo.net:50002", Network.MAINNET),
TESTNET_ARANGUREN_ORG("testnet.aranguren.org", "ssl://testnet.aranguren.org:51002", Network.TESTNET);
PublicElectrumServer(String name, String url, Network network) {
this.name = name;
this.url = url;
this.network = network;
}
public static final List<Network> SUPPORTED_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
private final String name;
private final String url;
private final Network network;
public String getName() {
return name;
@@ -23,6 +34,18 @@ public enum PublicElectrumServer {
return url;
}
public Network getNetwork() {
return network;
}
public static List<PublicElectrumServer> getServers() {
return Arrays.stream(values()).filter(server -> server.network == Network.get()).collect(Collectors.toList());
}
public static boolean supportedNetwork() {
return SUPPORTED_NETWORKS.contains(Network.get());
}
public static PublicElectrumServer fromUrl(String url) {
for(PublicElectrumServer server : values()) {
if(server.url.equals(url)) {
@@ -16,6 +16,7 @@ import java.io.*;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
@@ -25,7 +26,8 @@ public class TcpTransport implements Transport, Closeable {
private static final Logger log = LoggerFactory.getLogger(TcpTransport.class);
public static final int DEFAULT_PORT = 50001;
private static final int[] BASE_READ_TIMEOUT_SECS = {3, 8, 16, 34};
public static final int DEFAULT_MAX_TIMEOUT = 34;
private static final int[] BASE_READ_TIMEOUT_SECS = {3, 8, 16, DEFAULT_MAX_TIMEOUT};
private static final int[] SLOW_READ_TIMEOUT_SECS = {34, 68, 124, 208};
public static final long PER_REQUEST_READ_TIMEOUT_MILLIS = 50;
public static final int SOCKET_READ_TIMEOUT_MILLIS = 5000;
@@ -61,7 +63,13 @@ public class TcpTransport implements Transport, Closeable {
public TcpTransport(HostAndPort server, HostAndPort proxy) {
this.server = server;
this.socketFactory = (proxy == null ? SocketFactory.getDefault() : new ProxySocketFactory(proxy));
this.readTimeouts = (Config.get().getServerType() == ServerType.BITCOIN_CORE && Protocol.isOnionAddress(Config.get().getCoreServer()) ? SLOW_READ_TIMEOUT_SECS : BASE_READ_TIMEOUT_SECS);
int[] timeouts = (Config.get().getServerType() == ServerType.BITCOIN_CORE && Protocol.isOnionAddress(Config.get().getCoreServer()) ?
Arrays.copyOf(SLOW_READ_TIMEOUT_SECS, SLOW_READ_TIMEOUT_SECS.length) : Arrays.copyOf(BASE_READ_TIMEOUT_SECS, BASE_READ_TIMEOUT_SECS.length));
if(Config.get().getMaxServerTimeout() > timeouts[timeouts.length - 1]) {
timeouts[timeouts.length - 1] = Config.get().getMaxServerTimeout();
}
this.readTimeouts = timeouts;
}
@Override
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.control.ComboBoxTextField;
import com.sparrowwallet.sparrow.control.TextFieldValidator;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.*;
@@ -15,6 +16,7 @@ import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.text.Font;
@@ -22,6 +24,7 @@ import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
@@ -40,6 +43,7 @@ import java.io.FileInputStream;
import java.security.cert.CertificateFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
@@ -75,7 +79,10 @@ public class ServerPreferencesController extends PreferencesDetailController {
private Form coreForm;
@FXML
private TextField coreHost;
private ComboBox<String> recentCoreServers;
@FXML
private ComboBoxTextField coreHost;
@FXML
private TextField corePort;
@@ -114,7 +121,10 @@ public class ServerPreferencesController extends PreferencesDetailController {
private Form electrumForm;
@FXML
private TextField electrumHost;
private ComboBox<String> recentElectrumServers;
@FXML
private ComboBoxTextField electrumHost;
@FXML
private TextField electrumPort;
@@ -186,15 +196,16 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
});
ServerType serverType = config.getServerType() != null ?
(config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && Network.get() != Network.MAINNET ? ServerType.BITCOIN_CORE : config.getServerType()) :
(config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && !PublicElectrumServer.supportedNetwork() ? ServerType.BITCOIN_CORE : config.getServerType()) :
(config.getCoreServer() == null && config.getElectrumServer() != null ? ServerType.ELECTRUM_SERVER :
(config.getCoreServer() != null || Network.get() != Network.MAINNET ? ServerType.BITCOIN_CORE : ServerType.PUBLIC_ELECTRUM_SERVER));
if(Network.get() != Network.MAINNET) {
(config.getCoreServer() != null || !PublicElectrumServer.supportedNetwork() ? ServerType.BITCOIN_CORE : ServerType.PUBLIC_ELECTRUM_SERVER));
if(!PublicElectrumServer.supportedNetwork()) {
serverTypeSegmentedButton.getButtons().remove(publicElectrumToggle);
serverTypeToggleGroup.getToggles().remove(publicElectrumToggle);
}
serverTypeToggleGroup.selectToggle(serverTypeToggleGroup.getToggles().stream().filter(toggle -> toggle.getUserData() == serverType).findFirst().orElse(null));
publicElectrumServer.setItems(FXCollections.observableList(PublicElectrumServer.getServers()));
publicElectrumServer.getSelectionModel().selectedItemProperty().addListener(getPublicElectrumServerListener(config));
publicUseProxy.selectedProperty().bindBidirectional(useProxy.selectedProperty());
@@ -204,6 +215,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
corePort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
electrumPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
proxyPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
coreProxyPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
publicProxyPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
coreHost.textProperty().addListener(getBitcoinCoreListener(config));
@@ -255,6 +267,29 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
});
recentCoreServers.setConverter(new UrlHostConverter());
recentCoreServers.setItems(FXCollections.observableList(Config.get().getRecentCoreServers() == null ? new ArrayList<>() : Config.get().getRecentCoreServers()));
recentCoreServers.prefWidthProperty().bind(coreHost.widthProperty());
recentCoreServers.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null && Protocol.getProtocol(newValue) != null) {
HostAndPort hostAndPort = Protocol.getProtocol(newValue).getServerHostAndPort(newValue);
coreHost.setText(hostAndPort.getHost());
corePort.setText(Integer.toString(hostAndPort.getPort()));
}
});
recentElectrumServers.setConverter(new UrlHostConverter());
recentElectrumServers.setItems(FXCollections.observableList(Config.get().getRecentElectrumServers() == null ? new ArrayList<>() : Config.get().getRecentElectrumServers()));
recentElectrumServers.prefWidthProperty().bind(electrumHost.widthProperty());
recentElectrumServers.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null && Protocol.getProtocol(newValue) != null) {
HostAndPort hostAndPort = Protocol.getProtocol(newValue).getServerHostAndPort(newValue);
electrumHost.setText(hostAndPort.getHost());
electrumPort.setText(Integer.toString(hostAndPort.getPort()));
electrumUseSsl.setSelected(Protocol.getProtocol(newValue) == Protocol.SSL);
}
});
electrumUseSsl.selectedProperty().addListener((observable, oldValue, newValue) -> {
setElectrumServerInConfig(config);
electrumCertificate.setDisable(!newValue);
@@ -325,7 +360,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
PublicElectrumServer configPublicElectrumServer = PublicElectrumServer.fromUrl(config.getPublicElectrumServer());
if(configPublicElectrumServer == null) {
publicElectrumServer.setValue(PublicElectrumServer.values()[new Random().nextInt(PublicElectrumServer.values().length)]);
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
publicElectrumServer.setValue(servers.get(new Random().nextInt(servers.size())));
} else {
publicElectrumServer.setValue(configPublicElectrumServer);
}
@@ -804,4 +840,16 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
});
}
private static class UrlHostConverter extends StringConverter<String> {
@Override
public String toString(String serverUrl) {
return serverUrl == null || Protocol.getProtocol(serverUrl) == null ? "" : Protocol.getProtocol(serverUrl).getServerHostAndPort(serverUrl).getHost();
}
@Override
public String fromString(String string) {
return null;
}
}
}
@@ -0,0 +1,417 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.cahoots.CahootsContext;
import com.samourai.soroban.client.cahoots.OnlineCahootsMessage;
import com.samourai.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.cahoots.Cahoots;
import com.samourai.wallet.cahoots.CahootsType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.io.Config;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
public class CounterpartyController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(CounterpartyController.class);
private String walletId;
private Wallet wallet;
@FXML
private VBox step1;
@FXML
private VBox step2;
@FXML
private VBox step3;
@FXML
private VBox step4;
@FXML
private CopyableTextField payNym;
@FXML
private Button showPayNym;
@FXML
private PayNymAvatar payNymAvatar;
@FXML
private Button payNymButton;
@FXML
private PaymentCodeTextField paymentCode;
@FXML
private Button paymentCodeQR;
@FXML
private ComboBox<Wallet> mixWallet;
@FXML
private ProgressTimer step2Timer;
@FXML
private Label step2Desc;
@FXML
private Label mixingPartner;
@FXML
private PayNymAvatar mixPartnerAvatar;
@FXML
private Label meetingFail;
@FXML
private VBox mixDetails;
@FXML
private Label mixType;
@FXML
private Label mixFee;
@FXML
private ProgressTimer step3Timer;
@FXML
private Label step3Desc;
@FXML
private ProgressBar sorobanProgressBar;
@FXML
private Label sorobanProgressLabel;
@FXML
private Glyph mixDeclined;
@FXML
private TransactionDiagram transactionDiagram;
private final ObjectProperty<Boolean> meetingReceived = new SimpleObjectProperty<>(null);
private final ObjectProperty<Boolean> meetingAccepted = new SimpleObjectProperty<>(null);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
public void initializeView(String walletId, Wallet wallet) {
this.walletId = walletId;
this.wallet = wallet;
step1.managedProperty().bind(step1.visibleProperty());
step2.managedProperty().bind(step2.visibleProperty());
step3.managedProperty().bind(step3.visibleProperty());
step4.managedProperty().bind(step4.visibleProperty());
mixWallet.setConverter(new StringConverter<>() {
@Override
public String toString(Wallet wallet) {
return wallet == null ? "" : wallet.getFullDisplayName();
}
@Override
public Wallet fromString(String string) {
return null;
}
});
mixWallet.setItems(FXCollections.observableList(wallet.getAllWallets()));
mixWallet.setValue(wallet);
mixWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> setWallet(selectedWallet));
sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty());
sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.managedProperty().bind(mixDeclined.visibleProperty());
sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not());
step2Timer.visibleProperty().bind(mixingPartner.visibleProperty());
step3Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
step2.setVisible(false);
step3.setVisible(false);
step4.setVisible(false);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
throw new IllegalStateException("Soroban HD wallet must be set");
}
payNym.managedProperty().bind(payNym.visibleProperty());
showPayNym.managedProperty().bind(showPayNym.visibleProperty());
showPayNym.visibleProperty().bind(payNym.visibleProperty());
payNymAvatar.managedProperty().bind(payNymAvatar.visibleProperty());
payNymAvatar.visibleProperty().bind(payNym.visibleProperty());
payNymButton.managedProperty().bind(payNymButton.visibleProperty());
payNymButton.visibleProperty().bind(payNym.visibleProperty().not());
if(Config.get().isUsePayNym()) {
retrievePayNym(null);
} else {
payNym.setVisible(false);
}
paymentCode.setPaymentCode(soroban.getPaymentCode());
paymentCodeQR.prefHeightProperty().bind(paymentCode.heightProperty());
paymentCodeQR.prefWidthProperty().bind(showPayNym.widthProperty());
mixingPartner.managedProperty().bind(mixingPartner.visibleProperty());
meetingFail.managedProperty().bind(meetingFail.visibleProperty());
meetingFail.visibleProperty().bind(mixingPartner.visibleProperty().not());
mixDetails.managedProperty().bind(mixDetails.visibleProperty());
mixDetails.setVisible(false);
meetingAccepted.addListener((observable, oldValue, accepted) -> {
Platform.exitNestedEventLoop(meetingAccepted, accepted);
meetingReceived.set(null);
});
step2.visibleProperty().addListener((observable, oldValue, visible) -> {
if(visible) {
startCounterpartyMeetingReceive();
step2Timer.start(e -> {
step2Desc.setText("Mix declined due to timeout.");
meetingReceived.set(Boolean.FALSE);
});
}
});
step3.visibleProperty().addListener((observable, oldValue, visible) -> {
if(visible) {
meetingAccepted.set(Boolean.TRUE);
}
});
}
private void setWallet(Wallet wallet) {
this.walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
this.wallet = wallet;
}
private void startCounterpartyMeetingReceive() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
SparrowCahootsWallet counterpartyCahootsWallet = soroban.getCahootsWallet(wallet, 1);
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(counterpartyCahootsWallet);
sorobanMeetingService.receiveMeetingRequest(TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(requestMessage -> {
String code = requestMessage.getSender();
CahootsType cahootsType = requestMessage.getType();
PaymentCode paymentCodeInitiator = new PaymentCode(code);
updateMixPartner(soroban, paymentCodeInitiator, cahootsType);
Boolean accepted = (Boolean)Platform.enterNestedEventLoop(meetingAccepted);
sorobanMeetingService.sendMeetingResponse(paymentCodeInitiator, requestMessage, accepted)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(responseMessage -> {
if(accepted) {
startCounterpartyCollaboration(counterpartyCahootsWallet, paymentCodeInitiator, cahootsType);
followPaymentCode(soroban, paymentCodeInitiator);
}
}, error -> {
log.error("Error sending meeting response", error);
mixingPartner.setVisible(false);
});
}, error -> {
log.error("Failed to receive meeting request", error);
mixingPartner.setVisible(false);
});
} catch(Exception e) {
log.error("Error sending meeting response", e);
}
}
private void updateMixPartner(Soroban soroban, PaymentCode paymentCodeInitiator, CahootsType cahootsType) {
String code = paymentCodeInitiator.toString();
mixingPartner.setText(code.substring(0, 12) + "..." + code.substring(code.length() - 5));
if(Config.get().isUsePayNym()) {
mixPartnerAvatar.setPaymentCode(paymentCodeInitiator);
soroban.getPayNym(paymentCodeInitiator.toString()).subscribe(payNym -> {
mixingPartner.setText(payNym.nymName());
}, error -> {
//ignore, may not be a PayNym
});
}
if(cahootsType == CahootsType.STONEWALLX2) {
mixType.setText("Two person coinjoin (" + cahootsType.getLabel() + ")");
mixFee.setText("You pay half the miner fee");
} else if(cahootsType == CahootsType.STOWAWAY) {
mixType.setText("Payjoin (" + cahootsType.getLabel() + ")");
mixFee.setText("None");
} else {
mixType.setText(cahootsType.getLabel());
mixFee.setText("None");
}
mixDetails.setVisible(true);
meetingReceived.set(Boolean.TRUE);
}
private void startCounterpartyCollaboration(SparrowCahootsWallet counterpartyCahootsWallet, PaymentCode initiatorPaymentCode, CahootsType cahootsType) {
sorobanProgressLabel.setText("Creating mix transaction...");
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : walletUtxos.entrySet()) {
counterpartyCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
try {
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(counterpartyCahootsWallet);
CahootsContext cahootsContext = cahootsType == CahootsType.STONEWALLX2 ? CahootsContext.newCounterpartyStonewallx2() : CahootsContext.newCounterpartyStowaway();
sorobanCahootsService.contributor(counterpartyCahootsWallet.getAccount(), cahootsContext, initiatorPaymentCode, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanMessage -> {
OnlineCahootsMessage cahootsMessage = (OnlineCahootsMessage)sorobanMessage;
if(cahootsMessage != null) {
Cahoots cahoots = cahootsMessage.getCahoots();
sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5);
if(cahoots.getStep() == 3) {
sorobanProgressLabel.setText("Your mix partner is reviewing the transaction...");
step3Timer.start();
} else if(cahoots.getStep() >= 4) {
try {
Transaction transaction = getTransaction(cahoots);
if(transaction != null) {
transactionProperty.set(transaction);
updateTransactionDiagram(transactionDiagram, wallet, null, transaction);
next();
}
} catch(PSBTParseException e) {
log.error("Invalid collaborative PSBT created", e);
step3Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false);
}
}
}
}, error -> {
log.error("Error creating mix transaction", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length());
msg = msg.replace("#Cahoots", "mix transaction");
step3Desc.setText(msg);
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Error creating mix transaction", e);
sorobanProgressLabel.setText(e.getMessage());
}
}
private void followPaymentCode(Soroban soroban, PaymentCode paymentCodeInitiator) {
if(Config.get().isUsePayNym()) {
soroban.getAuthToken(new HashMap<>()).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.followPaymentCode(paymentCodeInitiator, authToken, signature).subscribe(followMap -> {
log.debug("Followed payment code " + followMap.get("following"));
}, error -> {
log.warn("Could not follow payment code", error);
});
});
}
}
public boolean next() {
if(step1.isVisible()) {
step1.setVisible(false);
step2.setVisible(true);
return true;
}
if(step2.isVisible()) {
step2.setVisible(false);
step3.setVisible(true);
return true;
}
if(step3.isVisible()) {
step3.setVisible(false);
step4.setVisible(true);
return true;
}
return false;
}
public void cancel() {
meetingAccepted.set(Boolean.FALSE);
}
public void retrievePayNym(ActionEvent event) {
Config.get().setUsePayNym(true);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.createPayNym().subscribe(createMap -> {
payNym.setText((String)createMap.get("nymName"));
payNymAvatar.setPaymentCode(soroban.getPaymentCode());
payNym.setVisible(true);
claimPayNym(soroban, createMap);
}, error -> {
log.error("Error retrieving PayNym", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
retrievePayNym(null);
} else {
payNym.setVisible(false);
}
});
}
public void showPayNym(ActionEvent event) {
PayNymDialog payNymDialog = new PayNymDialog(walletId, false);
payNymDialog.showAndWait();
}
public void showPayNymQR(ActionEvent event) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(soroban.getPaymentCode().toString());
qrDisplayDialog.showAndWait();
}
public ObjectProperty<Boolean> meetingReceivedProperty() {
return meetingReceived;
}
public ObjectProperty<Boolean> meetingAcceptedProperty() {
return meetingAccepted;
}
public ObjectProperty<Transaction> transactionProperty() {
return transactionProperty;
}
}
@@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import javafx.event.ActionEvent;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import java.io.IOException;
public class CounterpartyDialog extends Dialog<Boolean> {
public CounterpartyDialog(String walletId, Wallet wallet) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader counterpartyLoader = new FXMLLoader(AppServices.class.getResource("soroban/counterparty.fxml"));
dialogPane.setContent(counterpartyLoader.load());
CounterpartyController counterpartyController = counterpartyLoader.getController();
counterpartyController.initializeView(walletId, wallet);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(520);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/counterparty.css").toExternalForm());
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.APPLY);
dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, doneButtonType);
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
Button doneButton = (Button)dialogPane.lookupButton(doneButtonType);
doneButton.setDisable(true);
counterpartyController.meetingReceivedProperty().addListener((observable, oldValue, newValue) -> {
nextButton.setDisable(newValue != Boolean.TRUE);
});
counterpartyController.transactionProperty().addListener((observable, oldValue, newValue) -> {
nextButton.setVisible(false);
doneButton.setDisable(newValue == null);
cancelButton.setDisable(newValue != null);
});
nextButton.managedProperty().bind(nextButton.visibleProperty());
doneButton.managedProperty().bind(doneButton.visibleProperty());
doneButton.visibleProperty().bind(nextButton.visibleProperty().not());
nextButton.addEventFilter(ActionEvent.ACTION, event -> {
if(!counterpartyController.next()) {
nextButton.setVisible(false);
doneButton.setDefaultButton(true);
}
nextButton.setDisable(counterpartyController.meetingReceivedProperty().get() != Boolean.TRUE);
event.consume();
});
cancelButton.addEventFilter(ActionEvent.ACTION, event -> {
if(counterpartyController.meetingReceivedProperty().get() == Boolean.TRUE) {
counterpartyController.cancel();
}
});
setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY));
} catch(IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,535 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.cahoots.CahootsContext;
import com.samourai.soroban.client.cahoots.OnlineCahootsMessage;
import com.samourai.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.cahoots.Cahoots;
import com.samourai.wallet.cahoots.CahootsType;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.PayNymAvatar;
import com.sparrowwallet.sparrow.control.ProgressTimer;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import io.reactivex.Observable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.function.UnaryOperator;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS;
public class InitiatorController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(InitiatorController.class);
private static final PayNym FIND_FOLLOWERS = new PayNym(null, null, "Retrieve Contacts...", false, Collections.emptyList(), Collections.emptyList());
private String walletId;
private Wallet wallet;
private WalletTransaction walletTransaction;
@FXML
private VBox step1;
@FXML
private VBox step2;
@FXML
private VBox step3;
@FXML
private ComboBox<PayNym> payNymFollowers;
@FXML
private TextField counterparty;
@FXML
private ProgressIndicator payNymLoading;
@FXML
private Button findPayNym;
@FXML
private PayNymAvatar payNymAvatar;
@FXML
private ProgressTimer step2Timer;
@FXML
private Label step2Desc;
@FXML
private ProgressBar sorobanProgressBar;
@FXML
private Label sorobanProgressLabel;
@FXML
private Glyph mixDeclined;
@FXML
private ProgressTimer step3Timer;
@FXML
private Label step3Desc;
@FXML
private TransactionDiagram transactionDiagram;
private final StringProperty counterpartyPayNymName = new SimpleStringProperty();
private final ObjectProperty<PaymentCode> counterpartyPaymentCode = new SimpleObjectProperty<>(null);
private final ObjectProperty<Step> stepProperty = new SimpleObjectProperty<>(Step.SETUP);
private final ObjectProperty<Boolean> transactionAccepted = new SimpleObjectProperty<>(null);
private final ObjectProperty<Transaction> transactionProperty = new SimpleObjectProperty<>(null);
private CahootsType cahootsType = CahootsType.STONEWALLX2;
public void initializeView(String walletId, Wallet wallet, WalletTransaction walletTransaction) {
this.walletId = walletId;
this.wallet = wallet;
this.walletTransaction = walletTransaction;
step1.managedProperty().bind(step1.visibleProperty());
step2.managedProperty().bind(step2.visibleProperty());
step3.managedProperty().bind(step3.visibleProperty());
sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty());
sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.managedProperty().bind(mixDeclined.visibleProperty());
sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not());
step2Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty());
step2.setVisible(false);
step3.setVisible(false);
transactionAccepted.addListener((observable, oldValue, accepted) -> {
if(transactionProperty.get() != null) {
Platform.exitNestedEventLoop(transactionAccepted, accepted);
}
});
transactionProperty.addListener((observable, oldValue, transaction) -> {
if(transaction != null) {
updateTransactionDiagram(transactionDiagram, wallet, walletTransaction, transaction);
}
});
step2.visibleProperty().addListener((observable, oldValue, visible) -> {
if(visible) {
startInitiatorMeetingRequest();
step2Timer.start();
}
});
payNymLoading.managedProperty().bind(payNymLoading.visibleProperty());
payNymLoading.maxHeightProperty().bind(counterparty.heightProperty());
payNymLoading.setVisible(false);
payNymAvatar.managedProperty().bind(payNymAvatar.visibleProperty());
payNymFollowers.prefWidthProperty().bind(counterparty.widthProperty());
payNymFollowers.valueProperty().addListener((observable, oldValue, payNym) -> {
if(payNym == FIND_FOLLOWERS) {
Config.get().setUsePayNym(true);
setPayNymFollowers();
} else if(payNym != null) {
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
counterparty.setText(payNym.nymName());
step1.requestFocus();
}
});
payNymFollowers.setConverter(new StringConverter<>() {
@Override
public String toString(PayNym payNym) {
return payNym == null ? "" : payNym.nymName();
}
@Override
public PayNym fromString(String string) {
return null;
}
});
UnaryOperator<TextFormatter.Change> paymentCodeFilter = change -> {
String input = change.getControlNewText();
if(input.startsWith("P") && !input.contains("...")) {
try {
PaymentCode paymentCode = new PaymentCode(input);
if(paymentCode.isValid()) {
counterpartyPaymentCode.set(paymentCode);
if(payNymAvatar.getPaymentCode() == null || !input.equals(payNymAvatar.getPaymentCode().toString())) {
payNymAvatar.setPaymentCode(paymentCode);
}
TextInputControl control = (TextInputControl)change.getControl();
change.setText(input.substring(0, 12) + "..." + input.substring(input.length() - 5));
change.setRange(0, control.getLength());
change.setAnchor(change.getText().length());
change.setCaretPosition(change.getText().length());
}
} catch(Exception e) {
//ignore
}
}
return change;
};
counterparty.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
counterparty.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue.startsWith("P") && newValue.contains("...") && newValue.length() == 20 && counterpartyPaymentCode.get() != null) {
//Assumed valid payment code
} else if(Config.get().isUsePayNym() && PAYNYM_REGEX.matcher(newValue).matches()) {
if(!newValue.equals(counterpartyPayNymName.get())) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
payNymLoading.setVisible(true);
soroban.getPayNym(newValue).subscribe(payNym -> {
payNymLoading.setVisible(false);
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
}, error -> {
payNymLoading.setVisible(false);
//ignore, probably doesn't exist but will try again on meeting request
});
}
} else {
counterpartyPayNymName.set(null);
counterpartyPaymentCode.set(null);
payNymAvatar.setPaymentCode(null);
}
}
});
Payment payment = walletTransaction.getPayments().get(0);
if(payment.getAddress() instanceof PayNymAddress payNymAddress) {
PayNym payNym = payNymAddress.getPayNym();
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
counterparty.setText(payNym.nymName());
counterparty.setEditable(false);
findPayNym.setVisible(false);
cahootsType = CahootsType.STOWAWAY;
} else if(Config.get().isUsePayNym()) {
setPayNymFollowers();
} else {
List<PayNym> defaultList = new ArrayList<>();
defaultList.add(FIND_FOLLOWERS);
payNymFollowers.setItems(FXCollections.observableList(defaultList));
}
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(counterparty, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid counterparty", !isValidCounterparty()));
});
}
private void setPayNymFollowers() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() != null) {
soroban.getFollowing().subscribe(followerPayNyms -> {
findPayNym.setVisible(true);
payNymFollowers.setItems(FXCollections.observableList(followerPayNyms));
}, error -> {
if(error.getMessage().endsWith("404")) {
Config.get().setUsePayNym(false);
AppServices.showErrorDialog("Could not retrieve PayNym", "This wallet does not have an associated PayNym or any followers yet. You can retrieve the PayNym using the Find PayNym button.");
} else {
log.warn("Could not retrieve followers: ", error);
}
});
}
}
private void startInitiatorMeetingRequest() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
startInitiatorMeetingRequest(soroban, wallet);
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(this::startInitiatorMeetingRequest);
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
} else {
step2.setVisible(false);
step1.setVisible(true);
}
} else {
soroban.setHDWallet(wallet);
startInitiatorMeetingRequest(soroban, wallet);
}
} else {
startInitiatorMeetingRequest(soroban, wallet);
}
}
private void startInitiatorMeetingRequest(Soroban soroban, Wallet wallet) {
SparrowCahootsWallet initiatorCahootsWallet = soroban.getCahootsWallet(wallet, (long)walletTransaction.getFeeRate());
getPaymentCodeCounterparty(soroban).subscribe(paymentCodeCounterparty -> {
try {
SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, cahootsType)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(meetingRequest -> {
sorobanProgressLabel.setText("Waiting for mix partner...");
sorobanMeetingService.receiveMeetingResponse(paymentCodeCounterparty, meetingRequest, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanResponse -> {
if(sorobanResponse.isAccept()) {
sorobanProgressBar.setProgress(0.1);
sorobanProgressLabel.setText("Mix partner accepted!");
startInitiatorCollaborative(initiatorCahootsWallet, paymentCodeCounterparty);
} else {
step2Desc.setText("Mix partner declined.");
sorobanProgressLabel.setVisible(false);
}
}, error -> {
log.error("Error receiving meeting response", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length());
msg = msg.replace("#Cahoots", "mix transaction");
step2Desc.setText(msg);
sorobanProgressLabel.setVisible(false);
});
}, error -> {
log.error("Error sending meeting request", error);
step2Desc.setText(error.getMessage());
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Error sending meeting request", e);
}
}, error -> {
log.error("Could not retrieve payment code", error);
if(error.getMessage().endsWith("404")) {
step2Desc.setText("PayNym not found");
} else if(error.getMessage().endsWith("400")) {
step2Desc.setText("Could not retrieve PayNym");
} else {
step2Desc.setText(error.getMessage());
}
sorobanProgressLabel.setVisible(false);
});
}
private void startInitiatorCollaborative(SparrowCahootsWallet initiatorCahootsWallet, PaymentCode paymentCodeCounterparty) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Payment payment = walletTransaction.getPayments().get(0);
Map<BlockTransactionHashIndex, WalletNode> firstSetUtxos = walletTransaction.isCoinControlUsed() ? walletTransaction.getSelectedUtxoSets().get(0) : wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : firstSetUtxos.entrySet()) {
initiatorCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
CahootsContext cahootsContext = cahootsType == CahootsType.STONEWALLX2 ?
CahootsContext.newInitiatorStonewallx2(payment.getAmount(), payment.getAddress().toString()) :
CahootsContext.newInitiatorStowaway(payment.getAmount());
sorobanCahootsService.getSorobanService().getOnInteraction()
.observeOn(JavaFxScheduler.platform())
.subscribe(interaction -> {
Boolean accepted = (Boolean)Platform.enterNestedEventLoop(transactionAccepted);
if(accepted) {
interaction.sorobanAccept();
} else {
interaction.sorobanReject("Mix partner declined to broadcast the transaction.");
}
});
try {
sorobanCahootsService.initiator(initiatorCahootsWallet.getAccount(), cahootsContext, paymentCodeCounterparty, TIMEOUT_MS)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.subscribe(sorobanMessage -> {
OnlineCahootsMessage cahootsMessage = (OnlineCahootsMessage)sorobanMessage;
if(cahootsMessage != null) {
Cahoots cahoots = cahootsMessage.getCahoots();
sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5);
if(cahoots.getStep() >= 3) {
try {
Transaction transaction = getTransaction(cahoots);
if(transaction != null) {
transactionProperty.set(transaction);
if(cahoots.getStep() == 3) {
next();
step3Timer.start(e -> {
if(stepProperty.get() != Step.BROADCAST) {
step3Desc.setText("Transaction declined due to timeout.");
transactionAccepted.set(Boolean.FALSE);
}
});
} else if(cahoots.getStep() == 4) {
stepProperty.set(Step.BROADCAST);
}
}
} catch(PSBTParseException e) {
log.error("Invalid collaborative PSBT created", e);
step2Desc.setText("Invalid transaction created.");
sorobanProgressLabel.setVisible(false);
}
}
}
},
error -> {
log.error("Error creating mix transaction", error);
String cutFrom = "Exception: ";
int index = error.getMessage().lastIndexOf(cutFrom);
step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length()));
sorobanProgressLabel.setVisible(false);
});
} catch(Exception e) {
log.error("Soroban communication error", e);
}
}
public void next() {
if(step1.isVisible()) {
step1.setVisible(false);
step2.setVisible(true);
stepProperty.set(Step.COMMUNICATE);
return;
}
if(step2.isVisible()) {
step2.setVisible(false);
step3.setVisible(true);
stepProperty.set(Step.REVIEW);
}
}
private Observable<PaymentCode> getPaymentCodeCounterparty(Soroban soroban) {
if(counterpartyPaymentCode.get() != null) {
return Observable.just(counterpartyPaymentCode.get());
} else {
return soroban.getPayNym(counterparty.getText()).map(PayNym::paymentCode);
}
}
private boolean isValidCounterparty() {
if(counterpartyPaymentCode.get() != null) {
return true;
}
if(counterparty.getText().startsWith("P") && counterparty.getText().contains("...") && counterparty.getText().length() == 20) {
return true;
}
return PAYNYM_REGEX.matcher(counterparty.getText()).matches();
}
public void accept() {
transactionAccepted.set(Boolean.TRUE);
}
public void cancel() {
transactionAccepted.set(Boolean.FALSE);
}
public void findPayNym(ActionEvent event) {
PayNymDialog payNymDialog = new PayNymDialog(walletId, true);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
optPayNym.ifPresent(payNym -> {
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
counterparty.setText(payNym.nymName());
step1.requestFocus();
});
}
public ObjectProperty<PaymentCode> counterpartyPaymentCodeProperty() {
return counterpartyPaymentCode;
}
public ObjectProperty<Step> stepProperty() {
return stepProperty;
}
public Transaction getTransaction() {
return transactionProperty.get();
}
public ObjectProperty<Boolean> transactionAcceptedProperty() {
return transactionAccepted;
}
public enum Step {
SETUP, COMMUNICATE, REVIEW, BROADCAST
}
}
@@ -0,0 +1,141 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class InitiatorDialog extends Dialog<Transaction> {
private static final Logger log = LoggerFactory.getLogger(InitiatorDialog.class);
private final boolean confirmationRequired;
public InitiatorDialog(String walletId, Wallet wallet, WalletTransaction walletTransaction) {
this.confirmationRequired = AppServices.getSorobanServices().getSoroban(walletId).getHdWallet() != null;
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader initiatorLoader = new FXMLLoader(AppServices.class.getResource("soroban/initiator.fxml"));
dialogPane.setContent(initiatorLoader.load());
InitiatorController initiatorController = initiatorLoader.getController();
initiatorController.initializeView(walletId, wallet, walletTransaction);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(530);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/initiator.css").toExternalForm());
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType broadcastButtonType = new javafx.scene.control.ButtonType("Sign & Broadcast", ButtonBar.ButtonData.APPLY);
dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, broadcastButtonType);
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType);
nextButton.setDisable(initiatorController.counterpartyPaymentCodeProperty().get() == null);
broadcastButton.setDisable(true);
nextButton.managedProperty().bind(nextButton.visibleProperty());
broadcastButton.managedProperty().bind(broadcastButton.visibleProperty());
broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not());
initiatorController.counterpartyPaymentCodeProperty().addListener((observable, oldValue, paymentCode) -> {
nextButton.setDisable(paymentCode == null);
});
initiatorController.stepProperty().addListener((observable, oldValue, step) -> {
if(step == InitiatorController.Step.SETUP) {
nextButton.setDisable(false);
nextButton.setVisible(true);
} else if(step == InitiatorController.Step.COMMUNICATE) {
nextButton.setDisable(true);
nextButton.setVisible(true);
} else if(step == InitiatorController.Step.REVIEW) {
nextButton.setVisible(false);
broadcastButton.setDefaultButton(true);
broadcastButton.setDisable(false);
} else if(step == InitiatorController.Step.BROADCAST) {
setResult(initiatorController.getTransaction());
}
});
initiatorController.transactionAcceptedProperty().addListener((observable, oldValue, accepted) -> {
broadcastButton.setDisable(accepted != Boolean.TRUE);
});
nextButton.addEventFilter(ActionEvent.ACTION, event -> {
initiatorController.next();
event.consume();
});
cancelButton.addEventFilter(ActionEvent.ACTION, event -> {
initiatorController.cancel();
});
broadcastButton.addEventFilter(ActionEvent.ACTION, event -> {
acceptAndBroadcast(initiatorController, walletId, wallet);
event.consume();
});
setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY) ? initiatorController.getTransaction() : null);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
private void acceptAndBroadcast(InitiatorController initiatorController, String walletId, Wallet wallet) {
if(confirmationRequired && wallet.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
initiatorController.accept();
password.get().clear();
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> acceptAndBroadcast(initiatorController, walletId, wallet));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
initiatorController.accept();
}
}
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import java.util.List;
public record PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit, List<PayNym> following, List<PayNym> followers) {}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.drongo.address.P2WPKHAddress;
public final class PayNymAddress extends P2WPKHAddress {
private final PayNym payNym;
public PayNymAddress(PayNym payNym) {
super(new byte[20]);
this.payNym = payNym;
}
public PayNym getPayNym() {
return payNym;
}
public String toString() {
return payNym.nymName();
}
}
@@ -0,0 +1,417 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.crypto.Key;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.function.UnaryOperator;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class PayNymController extends SorobanController {
private static final Logger log = LoggerFactory.getLogger(PayNymController.class);
private String walletId;
private PayNym walletPayNym;
@FXML
private CopyableTextField payNymName;
@FXML
private Button payNymRetrieve;
@FXML
private ProgressIndicator retrievePayNymProgress;
@FXML
private PaymentCodeTextField paymentCode;
@FXML
private CopyableTextField searchPayNyms;
@FXML
private ProgressIndicator findPayNym;
@FXML
private PayNymAvatar payNymAvatar;
@FXML
private ListView<PayNym> followingList;
@FXML
private ListView<PayNym> followersList;
private final ObjectProperty<PayNym> payNymProperty = new SimpleObjectProperty<>(null);
private final StringProperty findNymProperty = new SimpleStringProperty();
public void initializeView(String walletId) {
this.walletId = walletId;
payNymName.managedProperty().bind(payNymName.visibleProperty());
payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty());
payNymRetrieve.visibleProperty().bind(payNymName.visibleProperty().not());
retrievePayNymProgress.managedProperty().bind(retrievePayNymProgress.visibleProperty());
retrievePayNymProgress.maxHeightProperty().bind(payNymName.heightProperty());
retrievePayNymProgress.setVisible(false);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() != null) {
paymentCode.setPaymentCode(soroban.getPaymentCode());
}
findNymProperty.addListener((observable, oldValue, nymIdentifier) -> {
if(nymIdentifier != null) {
searchFollowing(nymIdentifier);
}
});
UnaryOperator<TextFormatter.Change> paymentCodeFilter = change -> {
String input = change.getControlNewText();
if(input.startsWith("P") && !input.contains("...")) {
try {
PaymentCode paymentCode = new PaymentCode(input);
if(paymentCode.isValid()) {
findNymProperty.set(input);
TextInputControl control = (TextInputControl)change.getControl();
change.setText(input.substring(0, 12) + "..." + input.substring(input.length() - 5));
change.setRange(0, control.getLength());
change.setAnchor(change.getText().length());
change.setCaretPosition(change.getText().length());
}
} catch(Exception e) {
//ignore
}
} else if(PAYNYM_REGEX.matcher(input).matches()) {
findNymProperty.set(input);
} else {
findNymProperty.set(null);
resetFollowing();
}
return change;
};
searchPayNyms.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
findPayNym.managedProperty().bind(findPayNym.visibleProperty());
findPayNym.maxHeightProperty().bind(searchPayNyms.heightProperty());
findPayNym.setVisible(false);
followingList.setCellFactory(param -> {
return new PayNymCell(this);
});
followingList.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, payNym) -> {
payNymProperty.set(payNym);
});
followersList.setCellFactory(param -> {
return new PayNymCell(null);
});
followersList.setSelectionModel(new NoSelectionModel<>());
followersList.setFocusTraversable(false);
if(Config.get().isUsePayNym() && soroban.getPaymentCode() != null) {
refresh();
} else {
payNymName.setVisible(false);
}
}
private void refresh() {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getPaymentCode() == null) {
throw new IllegalStateException("Payment code has not been set");
}
retrievePayNymProgress.setVisible(true);
soroban.getPayNym(soroban.getPaymentCode().toString()).subscribe(payNym -> {
retrievePayNymProgress.setVisible(false);
walletPayNym = payNym;
payNymName.setText(payNym.nymName());
paymentCode.setPaymentCode(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
followingList.setUserData(null);
followingList.setPlaceholder(new Label("No contacts"));
followingList.setItems(FXCollections.observableList(payNym.following()));
followersList.setPlaceholder(new Label("No followers"));
followersList.setItems(FXCollections.observableList(payNym.followers()));
}, error -> {
retrievePayNymProgress.setVisible(false);
if(error.getMessage().endsWith("404")) {
payNymName.setVisible(false);
} else {
log.error("Error retrieving PayNym", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
refresh();
} else {
payNymName.setVisible(false);
}
}
});
}
private void resetFollowing() {
if(followingList.getUserData() != null) {
followingList.setUserData(null);
followingList.setItems(FXCollections.observableList(walletPayNym.following()));
}
}
private void searchFollowing(String nymIdentifier) {
Optional<PayNym> optExisting = walletPayNym.following().stream().filter(payNym -> payNym.nymName().equals(nymIdentifier) || payNym.paymentCode().toString().equals(nymIdentifier)).findFirst();
if(optExisting.isPresent()) {
followingList.setUserData(Boolean.FALSE);
List<PayNym> existingPayNym = new ArrayList<>();
existingPayNym.add(optExisting.get());
followingList.setItems(FXCollections.observableList(existingPayNym));
} else {
followingList.setUserData(Boolean.TRUE);
followingList.setItems(FXCollections.observableList(new ArrayList<>()));
findPayNym.setVisible(true);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.getPayNym(nymIdentifier).subscribe(searchedPayNym -> {
findPayNym.setVisible(false);
List<PayNym> searchList = new ArrayList<>();
searchList.add(searchedPayNym);
followingList.setUserData(Boolean.TRUE);
followingList.setItems(FXCollections.observableList(searchList));
}, error -> {
findPayNym.setVisible(false);
});
}
}
public void showQR(ActionEvent event) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(soroban.getPaymentCode().toString());
qrDisplayDialog.showAndWait();
}
public void scanQR(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optResult = qrScanDialog.showAndWait();
if(optResult.isPresent()) {
QRScanDialog.Result result = optResult.get();
if(result.payload != null) {
searchPayNyms.setText(result.payload);
} else {
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a payment code");
}
}
}
public void retrievePayNym(ActionEvent event) {
Config.get().setUsePayNym(true);
makeAuthenticatedCall(null);
}
public void followPayNym(PaymentCode paymentCode) {
makeAuthenticatedCall(paymentCode);
}
private void makeAuthenticatedCall(PaymentCode contact) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
if(soroban.getHdWallet() == null) {
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
makeAuthenticatedCall(soroban, contact);
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> makeAuthenticatedCall(contact));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
soroban.setHDWallet(wallet);
makeAuthenticatedCall(soroban, contact);
}
} else {
makeAuthenticatedCall(soroban, contact);
}
}
private void makeAuthenticatedCall(Soroban soroban, PaymentCode contact) {
if(contact != null) {
followPayNym(soroban, contact);
} else {
retrievePayNym(soroban);
}
}
private void retrievePayNym(Soroban soroban) {
soroban.createPayNym().subscribe(createMap -> {
payNymName.setText((String)createMap.get("nymName"));
payNymAvatar.setPaymentCode(soroban.getPaymentCode());
payNymName.setVisible(true);
claimPayNym(soroban, createMap);
refresh();
}, error -> {
log.error("Error retrieving PayNym", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
retrievePayNym(soroban);
}
});
}
private void followPayNym(Soroban soroban, PaymentCode contact) {
soroban.getAuthToken(new HashMap<>()).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.followPaymentCode(contact, authToken, signature).subscribe(followMap -> {
refresh();
}, error -> {
log.error("Could not follow payment code", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not follow payment code. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
followPayNym(soroban, contact);
} else {
followingList.refresh();
}
});
}, error -> {
log.error("Could not follow payment code", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not follow payment code. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
followPayNym(soroban, contact);
} else {
followingList.refresh();
}
});
}
public PayNym getPayNym() {
return payNymProperty.get();
}
public ObjectProperty<PayNym> payNymProperty() {
return payNymProperty;
}
public static class NoSelectionModel<T> extends MultipleSelectionModel<T> {
@Override
public ObservableList<Integer> getSelectedIndices() {
return FXCollections.emptyObservableList();
}
@Override
public ObservableList<T> getSelectedItems() {
return FXCollections.emptyObservableList();
}
@Override
public void selectIndices(int index, int... indices) {
}
@Override
public void selectAll() {
}
@Override
public void selectFirst() {
}
@Override
public void selectLast() {
}
@Override
public void clearAndSelect(int index) {
}
@Override
public void select(int index) {
}
@Override
public void select(T obj) {
}
@Override
public void clearSelection(int index) {
}
@Override
public void clearSelection() {
}
@Override
public boolean isSelected(int index) {
return false;
}
@Override
public boolean isEmpty() {
return true;
}
@Override
public void selectPrevious() {
}
@Override
public void selectNext() {
}
}
}
@@ -0,0 +1,49 @@
package com.sparrowwallet.sparrow.soroban;
import com.sparrowwallet.sparrow.AppServices;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import java.io.IOException;
public class PayNymDialog extends Dialog<PayNym> {
public PayNymDialog(String walletId, boolean selectPayNym) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader payNymLoader = new FXMLLoader(AppServices.class.getResource("soroban/paynym.fxml"));
dialogPane.setContent(payNymLoader.load());
PayNymController payNymController = payNymLoader.getController();
payNymController.initializeView(walletId);
dialogPane.setPrefWidth(730);
dialogPane.setPrefHeight(600);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/paynym.css").toExternalForm());
final ButtonType selectButtonType = new javafx.scene.control.ButtonType("Select Contact", ButtonBar.ButtonData.APPLY);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
if(selectPayNym) {
dialogPane.getButtonTypes().addAll(selectButtonType, cancelButtonType);
Button selectButton = (Button)dialogPane.lookupButton(selectButtonType);
selectButton.setDisable(true);
selectButton.setDefaultButton(true);
payNymController.payNymProperty().addListener((observable, oldValue, payNym) -> {
selectButton.setDisable(payNym == null);
});
} else {
dialogPane.getButtonTypes().add(doneButtonType);
}
setResultConverter(dialogButton -> dialogButton == selectButtonType ? payNymController.getPayNym() : null);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -0,0 +1,142 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.http.client.HttpUsage;
import com.samourai.http.client.IHttpClient;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import io.reactivex.Observable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.schedulers.Schedulers;
import java8.util.Optional;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@SuppressWarnings("unchecked")
public class PayNymService {
private final JavaHttpClientService httpClientService;
public PayNymService(JavaHttpClientService httpClientService) {
this.httpClientService = httpClientService;
}
public Observable<Map<String, Object>> createPayNym(PaymentCode paymentCode) {
if(paymentCode == null) {
throw new IllegalStateException("Payment code is null");
}
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("code", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/create", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> updateToken(PaymentCode paymentCode) {
if(paymentCode == null) {
throw new IllegalStateException("Payment code is null");
}
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("code", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/token", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> claimPayNym(String authToken, String signature) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
headers.put("auth-token", authToken);
HashMap<String, Object> body = new HashMap<>();
body.put("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/claim", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> addSamouraiPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
headers.put("auth-token", authToken);
HashMap<String, Object> body = new HashMap<>();
body.put("nym", paymentCode.toString());
body.put("code", paymentCode.makeSamouraiPaymentCode());
body.put("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/nym/add", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> followPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
headers.put("auth-token", authToken);
HashMap<String, Object> body = new HashMap<>();
body.put("signature", signature);
body.put("target", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/follow", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<Map<String, Object>> fetchPayNym(String nymIdentifier) {
Map<String, String> headers = new HashMap<>();
headers.put("content-type", "application/json");
HashMap<String, Object> body = new HashMap<>();
body.put("nym", nymIdentifier);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/nym", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
}
public Observable<PayNym> getPayNym(String nymIdentifier) {
return fetchPayNym(nymIdentifier).map(nymMap -> {
List<Map<String, Object>> codes = (List<Map<String, Object>>)nymMap.get("codes");
PaymentCode code = new PaymentCode((String)codes.stream().filter(codeMap -> codeMap.get("segwit") == Boolean.FALSE).map(codeMap -> codeMap.get("code")).findFirst().orElse(codes.get(0).get("code")));
List<Map<String, Object>> followingMaps = (List<Map<String, Object>>)nymMap.get("following");
List<PayNym> following = followingMaps.stream().map(followingMap -> {
return new PayNym(new PaymentCode((String)followingMap.get("code")), (String)followingMap.get("nymId"), (String)followingMap.get("nymName"), (Boolean)followingMap.get("segwit"), Collections.emptyList(), Collections.emptyList());
}).collect(Collectors.toList());
List<Map<String, Object>> followersMaps = (List<Map<String, Object>>)nymMap.get("followers");
List<PayNym> followers = followersMaps.stream().map(followerMap -> {
return new PayNym(new PaymentCode((String)followerMap.get("code")), (String)followerMap.get("nymId"), (String)followerMap.get("nymName"), (Boolean)followerMap.get("segwit"), Collections.emptyList(), Collections.emptyList());
}).collect(Collectors.toList());
return new PayNym(code, (String)nymMap.get("nymID"), (String)nymMap.get("nymName"), (Boolean)nymMap.get("segwit"), following, followers);
});
}
}
@@ -0,0 +1,208 @@
package com.sparrowwallet.sparrow.soroban;
import com.google.common.net.HostAndPort;
import com.samourai.http.client.HttpUsage;
import com.samourai.http.client.IHttpClient;
import com.samourai.soroban.client.SorobanServer;
import com.samourai.soroban.client.cahoots.SorobanCahootsService;
import com.samourai.soroban.client.rpc.RpcClient;
import com.samourai.wallet.bip47.rpc.BIP47Wallet;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.bip47.rpc.java.Bip47UtilJava;
import com.samourai.wallet.cahoots.CahootsWallet;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.wallet.hd.HD_WalletFactoryGeneric;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.DumpedPrivateKey;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import com.sparrowwallet.sparrow.AppServices;
import io.reactivex.Observable;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Provider;
import java.util.*;
public class Soroban {
private static final Logger log = LoggerFactory.getLogger(Soroban.class);
protected static final HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance();
protected static final Bip47UtilJava bip47Util = Bip47UtilJava.getInstance();
protected static final Provider PROVIDER_JAVA = Drongo.getProvider();
protected static final int TIMEOUT_MS = 60000;
public static final List<Network> SOROBAN_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
private final SorobanServer sorobanServer;
private final JavaHttpClientService httpClientService;
private final PayNymService payNymService;
private HD_Wallet hdWallet;
private BIP47Wallet bip47Wallet;
private PaymentCode paymentCode;
public Soroban(Network network, HostAndPort torProxy) {
this.sorobanServer = SorobanServer.valueOf(network.getName().toUpperCase());
this.httpClientService = new JavaHttpClientService(torProxy);
this.payNymService = new PayNymService(httpClientService);
}
public HD_Wallet getHdWallet() {
return hdWallet;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
public void setPaymentCode(Wallet wallet) {
if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted");
}
try {
Keystore keystore = wallet.getKeystores().get(0);
List<String> words = keystore.getSeed().getMnemonicCode();
String passphrase = keystore.getSeed().getPassphrase().asString();
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
BIP47Wallet bip47Wallet = hdWalletFactory.getBIP47(Utils.bytesToHex(seed), passphrase, sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47Wallet);
} catch(Exception e) {
throw new IllegalStateException("Could not create payment code", e);
}
}
public void setHDWallet(Wallet wallet) {
if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted");
}
try {
Keystore keystore = wallet.getKeystores().get(0);
ScriptType scriptType = wallet.getScriptType();
int purpose = scriptType.getDefaultDerivation().get(0).num();
List<String> words = keystore.getSeed().getMnemonicCode();
String passphrase = keystore.getSeed().getPassphrase().asString();
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase);
bip47Wallet = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams());
paymentCode = bip47Util.getPaymentCode(bip47Wallet);
} catch(Exception e) {
throw new IllegalStateException("Could not create Soroban HD wallet ", e);
}
}
public SparrowCahootsWallet getCahootsWallet(Wallet wallet, double feeRate) {
if(wallet.getScriptType() != ScriptType.P2WPKH) {
throw new IllegalArgumentException("Wallet must be P2WPKH");
}
if(hdWallet == null) {
for(Wallet associatedWallet : wallet.getAllWallets()) {
Soroban soroban = AppServices.getSorobanServices().getSoroban(associatedWallet);
if(soroban != null && soroban.getHdWallet() != null) {
hdWallet = soroban.hdWallet;
paymentCode = soroban.paymentCode;
}
}
}
if(hdWallet == null) {
throw new IllegalStateException("HD wallet is not set");
}
try {
return new SparrowCahootsWallet(wallet, hdWallet, sorobanServer, (long)feeRate);
} catch(Exception e) {
log.error("Could not create cahoots wallet", e);
}
return null;
}
public SorobanCahootsService getSorobanCahootsService(CahootsWallet cahootsWallet) {
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
RpcClient rpcClient = new RpcClient(httpClient, httpClientService.getTorProxy() != null, sorobanServer.getParams());
return new SorobanCahootsService(bip47Util, PROVIDER_JAVA, cahootsWallet, rpcClient);
}
public HostAndPort getTorProxy() {
return httpClientService.getTorProxy();
}
public void setTorProxy(HostAndPort torProxy) {
//Ensure all http clients are shutdown first
httpClientService.shutdown();
httpClientService.setTorProxy(torProxy);
}
public void shutdown() {
httpClientService.shutdown();
}
public Observable<Map<String, Object>> createPayNym() {
return payNymService.createPayNym(paymentCode);
}
public Observable<Map<String, Object>> updateToken() {
return payNymService.updateToken(paymentCode);
}
public Observable<Map<String, Object>> claimPayNym(String authToken, String signature) {
return payNymService.claimPayNym(authToken, signature);
}
public Observable<Map<String, Object>> addSamouraiPaymentCode(String authToken, String signature) {
return payNymService.addSamouraiPaymentCode(paymentCode, authToken, signature);
}
public Observable<Map<String, Object>> followPaymentCode(PaymentCode paymentCode, String authToken, String signature) {
return payNymService.followPaymentCode(paymentCode, authToken, signature);
}
public Observable<PayNym> getPayNym(String nymIdentifier) {
return payNymService.getPayNym(nymIdentifier);
}
public Observable<List<PayNym>> getFollowing() {
return payNymService.getPayNym(paymentCode.toString()).map(PayNym::following);
}
public Observable<String> getAuthToken(Map<String, Object> map) {
if(map.containsKey("token")) {
return Observable.just((String)map.get("token"));
}
return updateToken().map(tokenMap -> (String)tokenMap.get("token"));
}
public String getSignature(String authToken) {
ECKey notificationAddressKey = DumpedPrivateKey.fromBase58(bip47Wallet.getAccount(0).addressAt(0).getPrivateKeyString()).getKey();
return notificationAddressKey.signMessage(authToken, ScriptType.P2PKH);
}
public static class ShutdownService extends Service<Boolean> {
private final Soroban soroban;
public ShutdownService(Soroban soroban) {
this.soroban = soroban;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws Exception {
soroban.shutdown();
return true;
}
};
}
}
}
@@ -0,0 +1,154 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.wallet.cahoots.Cahoots;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.TransactionDiagram;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class SorobanController {
private static final Logger log = LoggerFactory.getLogger(SorobanController.class);
protected static final Pattern PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]");
protected void claimPayNym(Soroban soroban, Map<String, Object> createMap) {
if(createMap.get("claimed") == Boolean.FALSE) {
soroban.getAuthToken(createMap).subscribe(authToken -> {
String signature = soroban.getSignature(authToken);
soroban.claimPayNym(authToken, signature).subscribe(claimMap -> {
log.debug("Claimed payment code " + claimMap.get("claimed"));
soroban.addSamouraiPaymentCode(authToken, signature).subscribe(addMap -> {
log.debug("Added payment code " + addMap);
});
}, error -> {
soroban.getAuthToken(new HashMap<>()).subscribe(newAuthToken -> {
String newSignature = soroban.getSignature(newAuthToken);
soroban.claimPayNym(newAuthToken, newSignature).subscribe(claimMap -> {
log.debug("Claimed payment code " + claimMap.get("claimed"));
soroban.addSamouraiPaymentCode(newAuthToken, newSignature).subscribe(addMap -> {
log.debug("Added payment code " + addMap);
});
}, newError -> {
log.error("Error claiming PayNym with new authToken", newError);
});
}, newError -> {
log.error("Error retrieving new authToken", newError);
});
});
}, error -> {
log.error("Error retrieving authToken", error);
});
}
}
protected Transaction getTransaction(Cahoots cahoots) throws PSBTParseException {
if(cahoots.getPSBT() != null) {
PSBT psbt = new PSBT(cahoots.getPSBT().toBytes());
return psbt.getTransaction();
}
return null;
}
protected void updateTransactionDiagram(TransactionDiagram transactionDiagram, Wallet wallet, WalletTransaction walletTransaction, Transaction transaction) {
WalletTransaction txWalletTransaction = getWalletTransaction(wallet, walletTransaction, transaction, null);
transactionDiagram.update(txWalletTransaction);
if(txWalletTransaction.getSelectedUtxoSets().size() == 2) {
Set<Sha256Hash> references = txWalletTransaction.getSelectedUtxoSets().get(1).keySet().stream().map(BlockTransactionHash::getHash).collect(Collectors.toSet());
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(references);
transactionReferenceService.setOnSucceeded(successEvent -> {
Map<Sha256Hash, BlockTransaction> transactionMap = transactionReferenceService.getValue();
transactionDiagram.update(getWalletTransaction(wallet, walletTransaction, transaction, transactionMap));
});
transactionReferenceService.setOnFailed(failedEvent -> {
log.error("Failed to retrieve referenced transactions", failedEvent.getSource().getException());
});
transactionReferenceService.start();
}
}
private WalletTransaction getWalletTransaction(Wallet wallet, WalletTransaction walletTransaction, Transaction transaction, Map<Sha256Hash, BlockTransaction> inputTransactions) {
Map<BlockTransactionHashIndex, WalletNode> allWalletUtxos = wallet.getWalletTxos();
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = new LinkedHashMap<>();
Map<BlockTransactionHashIndex, WalletNode> externalUtxos = new LinkedHashMap<>();
for(TransactionInput txInput : transaction.getInputs()) {
Optional<BlockTransactionHashIndex> optWalletUtxo = allWalletUtxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst();
if(optWalletUtxo.isPresent()) {
walletUtxos.put(optWalletUtxo.get(), allWalletUtxos.get(optWalletUtxo.get()));
} else {
BlockTransactionHashIndex externalUtxo;
if(inputTransactions != null && inputTransactions.containsKey(txInput.getOutpoint().getHash())) {
BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash());
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
externalUtxo = new BlockTransactionHashIndex(blockTransaction.getHash(), blockTransaction.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), txInput.getOutpoint().getIndex(), txOutput.getValue());
} else {
externalUtxo = new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0);
}
externalUtxos.put(externalUtxo, null);
}
}
List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets = new ArrayList<>();
selectedUtxoSets.add(walletUtxos);
selectedUtxoSets.add(externalUtxos);
Map<Address, WalletNode> walletAddresses = wallet.getWalletAddresses();
List<Payment> payments = new ArrayList<>();
Map<WalletNode, Long> changeMap = new LinkedHashMap<>();
for(TransactionOutput txOutput : transaction.getOutputs()) {
Address address = txOutput.getScript().getToAddress();
if(address != null) {
Optional<Payment> optPayment = walletTransaction == null ? Optional.empty() :
walletTransaction.getPayments().stream().filter(payment -> payment.getAddress().equals(address) && payment.getAmount() == txOutput.getValue()).findFirst();
if(optPayment.isPresent()) {
payments.add(optPayment.get());
} else if(walletAddresses.containsKey(address) && walletAddresses.get(address).getKeyPurpose() == KeyPurpose.CHANGE) {
changeMap.put(walletAddresses.get(address), txOutput.getValue());
} else {
Payment payment = new Payment(address, null, txOutput.getValue(), false);
if(transaction.getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) {
payment.setType(Payment.Type.MIX);
}
payments.add(payment);
}
}
}
long fee = calculateFee(walletTransaction, selectedUtxoSets, transaction);
return new WalletTransaction(wallet, transaction, Collections.emptyList(), selectedUtxoSets, payments, changeMap, fee, inputTransactions);
}
private long calculateFee(WalletTransaction walletTransaction, List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets, Transaction transaction) {
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = new LinkedHashMap<>();
selectedUtxoSets.forEach(selectedUtxos::putAll);
long feeAmt = 0L;
for(BlockTransactionHashIndex utxo : selectedUtxos.keySet()) {
if(utxo.getValue() == 0) {
return walletTransaction == null ? -1 : walletTransaction.getFee();
}
feeAmt += utxo.getValue();
}
for(TransactionOutput txOutput : transaction.getOutputs()) {
feeAmt -= txOutput.getValue();
}
return feeAmt;
}
}
@@ -0,0 +1,82 @@
package com.sparrowwallet.sparrow.soroban;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.WalletTabsClosedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.TorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class SorobanServices {
private static final Logger log = LoggerFactory.getLogger(SorobanServices.class);
private final Map<String, Soroban> sorobanMap = new HashMap<>();
public Soroban getSoroban(Wallet wallet) {
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
for(Map.Entry<Wallet, Storage> entry : AppServices.get().getOpenWallets().entrySet()) {
if(entry.getKey() == masterWallet) {
return sorobanMap.get(entry.getValue().getWalletId(entry.getKey()));
}
}
return null;
}
public Soroban getSoroban(String walletId) {
Soroban soroban = sorobanMap.get(walletId);
if(soroban == null) {
HostAndPort torProxy = getTorProxy();
soroban = new Soroban(Network.get(), torProxy);
sorobanMap.put(walletId, soroban);
} else {
HostAndPort torProxy = getTorProxy();
if(!Objects.equals(soroban.getTorProxy(), torProxy)) {
soroban.setTorProxy(getTorProxy());
}
}
return soroban;
}
private HostAndPort getTorProxy() {
return AppServices.isTorRunning() ?
HostAndPort.fromParts("localhost", TorService.PROXY_PORT) :
(Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer()));
}
public static boolean canWalletMix(Wallet wallet) {
return Soroban.SOROBAN_NETWORKS.contains(Network.get())
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().get(0).hasSeed()
&& wallet.getKeystores().get(0).getSeed().getType() == DeterministicSeed.Type.BIP39
&& wallet.getScriptType() == ScriptType.P2WPKH;
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData walletTabData : event.getClosedWalletTabData()) {
String walletId = walletTabData.getStorage().getWalletId(walletTabData.getWallet());
Soroban soroban = sorobanMap.remove(walletId);
if(soroban != null) {
Soroban.ShutdownService shutdownService = new Soroban.ShutdownService(soroban);
shutdownService.setOnFailed(failedEvent -> {
log.error("Failed to shutdown soroban", failedEvent.getSource().getException());
});
shutdownService.start();
}
}
}
}
@@ -0,0 +1,56 @@
package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.client.SorobanServer;
import com.samourai.wallet.api.backend.beans.UnspentOutput;
import com.samourai.wallet.cahoots.CahootsUtxo;
import com.samourai.wallet.cahoots.SimpleCahootsWallet;
import com.samourai.wallet.hd.HD_Address;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.wallet.send.MyTransactionOutPoint;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import org.apache.commons.lang3.tuple.Pair;
public class SparrowCahootsWallet extends SimpleCahootsWallet {
private final Wallet wallet;
private final int account;
public SparrowCahootsWallet(Wallet wallet, HD_Wallet bip84w, SorobanServer sorobanServer, long feePerB) throws Exception {
super(bip84w, sorobanServer.getParams(), wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), feePerB);
this.wallet = wallet;
this.account = wallet.getAccountIndex();
bip84w.getAccount(account).getReceive().setAddrIdx(wallet.getFreshNode(KeyPurpose.RECEIVE).getIndex());
bip84w.getAccount(account).getChange().setAddrIdx(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex());
}
public void addUtxo(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) {
UnspentOutput unspentOutput = Whirlpool.getUnspentOutput(wallet, node, blockTransaction, index);
MyTransactionOutPoint myTransactionOutPoint = unspentOutput.computeOutpoint(getParams());
HD_Address hdAddress = getBip84Wallet().getAddressAt(account, unspentOutput);
CahootsUtxo cahootsUtxo = new CahootsUtxo(myTransactionOutPoint, node.getDerivationPath(), hdAddress.getECKey());
addUtxo(account, cahootsUtxo);
}
public int getAccount() {
return account;
}
@Override
public Pair<Integer, Integer> fetchReceiveIndex(int account) throws Exception {
if(account == StandardAccount.WHIRLPOOL_POSTMIX.getAccountNumber()) {
// force change chain
return Pair.of(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), 1);
}
return Pair.of(wallet.getFreshNode(KeyPurpose.RECEIVE).getIndex(), 0);
}
@Override
public Pair<Integer, Integer> fetchChangeIndex(int account) throws Exception {
return Pair.of(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), 1);
}
}
@@ -30,14 +30,18 @@ import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.DateCell;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -52,6 +56,7 @@ import java.net.*;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@@ -61,11 +66,17 @@ public class HeadersController extends TransactionFormController implements Init
public static final String BLOCK_TIMESTAMP_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss ZZZ";
public static final String UNFINALIZED_TXID_CLASS = "unfinalized-txid";
public static final String MAX_LOCKTIME_DATE = "2106-02-07T06:28:15Z";
public static final String MIN_LOCKTIME_DATE = "1985-11-05T00:53:20Z";
private HeadersForm headersForm;
@FXML
private IdLabel id;
@FXML
private TransactionDiagram transactionDiagram;
@FXML
private Spinner<Integer> version;
@@ -315,8 +326,22 @@ public class HeadersController extends TransactionFormController implements Init
}
});
LocalDateTime maxLocktimeDate = Instant.parse(MAX_LOCKTIME_DATE).atZone(ZoneId.systemDefault()).toLocalDateTime();
LocalDateTime minLocktimeDate = Instant.parse(MIN_LOCKTIME_DATE).atZone(ZoneId.systemDefault()).toLocalDateTime();
locktimeDate.setDayCellFactory(d ->
new DateCell() {
@Override
public void updateItem(LocalDate item, boolean empty) {
super.updateItem(item, empty);
setDisable(item.isAfter(maxLocktimeDate.toLocalDate()) || item.isBefore(minLocktimeDate.toLocalDate()));
}
});
locktimeDate.setFormat(LOCKTIME_DATE_FORMAT);
locktimeDate.dateTimeValueProperty().addListener((obs, oldValue, newValue) -> {
int caret = locktimeDate.getEditor().getCaretPosition();
locktimeDate.getEditor().setText(newValue.format(DateTimeFormatter.ofPattern(locktimeDate.getFormat())));
locktimeDate.getEditor().positionCaret(caret);
tx.setLocktime(newValue.toEpochSecond(OffsetDateTime.now(ZoneId.systemDefault()).getOffset()));
futureDateWarning.setVisible(newValue.isAfter(LocalDateTime.now()));
if(oldValue != null) {
@@ -324,6 +349,41 @@ public class HeadersController extends TransactionFormController implements Init
}
});
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(LOCKTIME_DATE_FORMAT);
locktimeDate.setConverter(new StringConverter<LocalDate>() {
public String toString(LocalDate object) {
LocalDateTime value = locktimeDate.getDateTimeValue();
return (value != null) ? value.format(formatter) : "";
}
public LocalDate fromString(String value) {
if(value == null) {
locktimeDate.setDateTimeValue(null);
return null;
}
LocalDateTime localDateTime = LocalDateTime.parse(value, formatter);
if(localDateTime.isAfter(maxLocktimeDate) || localDateTime.isBefore(minLocktimeDate)) {
throw new IllegalArgumentException("Invalid locktime date");
}
locktimeDate.setDateTimeValue(localDateTime);
return locktimeDate.getDateTimeValue().toLocalDate();
}
});
locktimeDate.getEditor().textProperty().addListener((observable, oldValue, newValue) -> {
String controlValue = locktimeDate.getDateTimeValue().format(DateTimeFormatter.ofPattern(locktimeDate.getFormat()));
if(!controlValue.equals(newValue) && !locktimeDate.getStyleClass().contains("edited")) {
locktimeDate.getStyleClass().add("edited");
}
});
locktimeDate.addEventFilter(KeyEvent.ANY, event -> {
if(event.getCode() == KeyCode.ENTER) {
locktimeDate.getStyleClass().remove("edited");
}
});
boolean locktimeEnabled = headersForm.getTransaction().isLocktimeSequenceEnabled();
locktimeNoneType.setDisable(!headersForm.isEditable() || !locktimeEnabled);
locktimeBlockType.setDisable(!headersForm.isEditable() || !locktimeEnabled);
@@ -347,6 +407,8 @@ public class HeadersController extends TransactionFormController implements Init
updateFee(feeAmt);
}
transactionDiagram.update(getWalletTransaction(headersForm.getInputTransactions()));
blockchainForm.managedProperty().bind(blockchainForm.visibleProperty());
signingWalletForm.managedProperty().bind(signingWalletForm.visibleProperty());
@@ -489,6 +551,94 @@ public class HeadersController extends TransactionFormController implements Init
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB" + (headersForm.isTransactionFinalized() ? "" : " (non-final)"));
}
private WalletTransaction getWalletTransaction(Map<Sha256Hash, BlockTransaction> inputTransactions) {
Wallet wallet = getWalletFromTransactionInputs();
if(wallet != null) {
Map<BlockTransactionHashIndex, WalletNode> selectedTxos = new LinkedHashMap<>();
Map<BlockTransactionHashIndex, WalletNode> walletTxos = wallet.getWalletTxos();
for(TransactionInput txInput : headersForm.getTransaction().getInputs()) {
BlockTransactionHashIndex selectedTxo = walletTxos.keySet().stream().filter(txo -> txInput.getOutpoint().getHash().equals(txo.getHash()) && txInput.getOutpoint().getIndex() == txo.getIndex())
.findFirst().orElse(new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0));
selectedTxos.put(selectedTxo, walletTxos.get(selectedTxo));
}
List<Payment> payments = new ArrayList<>();
Map<WalletNode, Long> changeMap = new LinkedHashMap<>();
Map<Script, WalletNode> changeOutputScripts = wallet.getWalletOutputScripts(KeyPurpose.CHANGE);
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));
} else {
payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX));
}
} else {
changeMap.put(changeNode, txOutput.getValue());
}
} else {
Payment.Type paymentType = Payment.Type.DEFAULT;
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
Wallet premixWallet = masterWallet.getChildWallet(StandardAccount.WHIRLPOOL_PREMIX);
if(premixWallet != null && headersForm.getTransaction().getOutputs().stream().anyMatch(premixWallet::isWalletTxo) && txOutput.getIndex() == 1) {
paymentType = Payment.Type.WHIRLPOOL_FEE;
}
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 {
payments.add(new Payment(txOutput.getScript().getToAddresses()[0], receivedTxo != null ? receivedTxo.getLabel() : label, txOutput.getValue(), false, paymentType));
} catch(Exception e) {
//ignore
}
}
}
return new WalletTransaction(wallet, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, changeMap, fee.getValue(), inputTransactions);
} else {
Map<BlockTransactionHashIndex, WalletNode> selectedTxos = headersForm.getTransaction().getInputs().stream()
.collect(Collectors.toMap(txInput -> {
if(inputTransactions != null) {
BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash());
if(blockTransaction != null) {
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
return new BlockTransactionHashIndex(blockTransaction.getHash(), blockTransaction.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), txInput.getOutpoint().getIndex(), txOutput.getValue());
}
}
return new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0);
},
txInput -> new WalletNode("m/0"),
(u, v) -> { throw new IllegalStateException("Duplicate TXOs"); },
LinkedHashMap::new));
selectedTxos.entrySet().forEach(entry -> entry.setValue(null));
List<Payment> payments = new ArrayList<>();
for(TransactionOutput txOutput : headersForm.getTransaction().getOutputs()) {
try {
payments.add(new Payment(txOutput.getScript().getToAddresses()[0], null, txOutput.getValue(), false));
} catch(Exception e) {
//ignore
}
}
return new WalletTransaction(null, headersForm.getTransaction(), Collections.emptyList(), List.of(selectedTxos), payments, Collections.emptyMap(), fee.getValue(), inputTransactions);
}
}
private Wallet getWalletFromTransactionInputs() {
for(TransactionInput txInput : headersForm.getTransaction().getInputs()) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet.isWalletTxo(txInput)) {
return openWallet;
}
}
}
return null;
}
private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) {
signaturesForm.setVisible(false);
blockchainForm.setVisible(true);
@@ -986,6 +1136,7 @@ public class HeadersController extends TransactionFormController implements Init
if(feeAmt != null) {
updateFee(feeAmt);
}
transactionDiagram.update(getWalletTransaction(event.getInputTransactions()));
}
}
@@ -1120,6 +1271,7 @@ public class HeadersController extends TransactionFormController implements Init
updateType();
updateSize();
updateFee(headersForm.getPsbt().getFee());
transactionDiagram.update(getWalletTransaction(headersForm.getInputTransactions()));
}
}
@@ -146,11 +146,11 @@ public class InputController extends TransactionFormController implements Initia
String baseText = getLegendText(txInput);
if(signingWallet != null) {
if(inputForm.isWalletTxo()) {
inputFieldset.setText(baseText + " - " + signingWallet.getFullName());
inputFieldset.setText(baseText + " from " + signingWallet.getFullDisplayName());
inputFieldset.setIcon(TransactionDiagram.getTxoGlyph());
} else {
inputFieldset.setText(baseText + " - Payjoin");
inputFieldset.setIcon(TransactionDiagram.getPayjoinGlyph());
inputFieldset.setText(baseText + " - External");
inputFieldset.setIcon(TransactionDiagram.getMixGlyph());
}
} else {
inputFieldset.setText(baseText);
@@ -152,7 +152,7 @@ public class TransactionController implements Initializable {
if(inputForm.isWalletTxo()) {
setGraphic(TransactionDiagram.getTxoGlyph());
} else {
setGraphic(TransactionDiagram.getPayjoinGlyph());
setGraphic(TransactionDiagram.getMixGlyph());
}
}
if(form instanceof OutputForm) {
@@ -39,6 +39,8 @@ import java.util.stream.Collectors;
public class KeystoreController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(KeystoreController.class);
private static final String DEFAULT_WATCH_ONLY_FINGERPRINT = "00000000";
private Keystore keystore;
@FXML
@@ -215,6 +217,7 @@ public class KeystoreController extends WalletFormController implements Initiali
if(keystoreSource != KeystoreSource.SW_WATCH) {
launchImportDialog(keystoreSource);
} else {
fingerprint.setText(DEFAULT_WATCH_ONLY_FINGERPRINT);
selectSourcePane.setVisible(false);
}
}
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.wallet;
import com.samourai.whirlpool.client.wallet.beans.IndexRange;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.MixConfig;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -13,6 +15,7 @@ import javafx.fxml.Initializable;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.util.StringConverter;
import java.net.URL;
import java.util.ArrayList;
@@ -30,6 +33,9 @@ public class MixToController implements Initializable {
@FXML
private Spinner<Integer> minMixes;
@FXML
private ComboBox<IndexRange> indexRange;
private MixConfig mixConfig;
@Override
@@ -44,11 +50,23 @@ public class MixToController implements Initializable {
allWallets.add(NONE_WALLET);
List<Wallet> destinationWallets = AppServices.get().getOpenWallets().keySet().stream().filter(openWallet -> openWallet.isValid()
&& (openWallet.getScriptType() == ScriptType.P2WPKH || openWallet.getScriptType() == ScriptType.P2WSH)
&& openWallet != wallet && openWallet != wallet.getMasterWallet()
&& (openWallet.getStandardAccountType() == null || !StandardAccount.WHIRLPOOL_ACCOUNTS.contains(openWallet.getStandardAccountType()))).collect(Collectors.toList());
allWallets.addAll(destinationWallets);
mixToWallets.setItems(FXCollections.observableList(allWallets));
mixToWallets.setConverter(new StringConverter<>() {
@Override
public String toString(Wallet wallet) {
return wallet == null ? "" : wallet.getFullDisplayName();
}
@Override
public Wallet fromString(String string) {
return null;
}
});
String mixToWalletId = null;
try {
@@ -81,6 +99,35 @@ public class MixToController implements Initializable {
mixConfig.setMinMixes(newValue);
EventManager.get().post(new MixToConfigChangedEvent(wallet));
});
indexRange.setConverter(new StringConverter<>() {
@Override
public String toString(IndexRange indexRange) {
if(indexRange == null) {
return "";
}
return indexRange.toString().charAt(0) + indexRange.toString().substring(1).toLowerCase();
}
@Override
public IndexRange fromString(String string) {
return null;
}
});
indexRange.setValue(IndexRange.FULL);
if(mixConfig.getIndexRange() != null) {
try {
indexRange.setValue(IndexRange.valueOf(mixConfig.getIndexRange()));
} catch(Exception e) {
//ignore
}
}
indexRange.valueProperty().addListener((observable, oldValue, newValue) -> {
mixConfig.setIndexRange(newValue.toString());
EventManager.get().post(new MixToConfigChangedEvent(wallet));
});
}
public MixConfig getMixConfig() {
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.address.P2PKHAddress;
@@ -16,18 +17,27 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.soroban.PayNym;
import com.sparrowwallet.sparrow.soroban.PayNymAddress;
import com.sparrowwallet.sparrow.soroban.PayNymDialog;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.*;
import javafx.util.StringConverter;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
@@ -38,6 +48,7 @@ import java.net.URL;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
@@ -49,7 +60,10 @@ public class PaymentController extends WalletFormController implements Initializ
private ValidationSupport validationSupport;
@FXML
private CopyableTextField address;
private ComboBox<Wallet> openWallets;
@FXML
private ComboBoxTextField address;
@FXML
private TextField label;
@@ -109,6 +123,15 @@ public class PaymentController extends WalletFormController implements Initializ
}
};
private final ObjectProperty<PayNym> payNymProperty = new SimpleObjectProperty<>();
private static final Wallet payNymWallet = new Wallet() {
@Override
public String getFullDisplayName() {
return "PayNym...";
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -121,7 +144,49 @@ public class PaymentController extends WalletFormController implements Initializ
@Override
public void initializeView() {
openWallets.setConverter(new StringConverter<>() {
@Override
public String toString(Wallet wallet) {
return wallet == null ? "" : wallet.getFullDisplayName() + (wallet == sendController.getWalletForm().getWallet() ? " (Consolidation)" : "");
}
@Override
public Wallet fromString(String string) {
return null;
}
});
updateOpenWallets();
openWallets.prefWidthProperty().bind(address.widthProperty());
openWallets.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue == payNymWallet) {
PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
if(optPayNym.isPresent()) {
PayNym payNym = optPayNym.get();
payNymProperty.set(payNym);
address.setText(payNym.nymName());
label.requestFocus();
}
} else if(newValue != null) {
WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE);
Address freshAddress = newValue.getAddress(freshNode);
address.setText(freshAddress.toString());
label.requestFocus();
}
});
payNymProperty.addListener((observable, oldValue, newValue) -> {
addPaymentButton.setDisable(newValue != null);
if(newValue != null) {
sendController.setPayNymPayment();
}
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
try {
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
Platform.runLater(() -> updateFromURI(bitcoinURI));
@@ -183,6 +248,20 @@ public class PaymentController extends WalletFormController implements Initializ
addValidation(validationSupport);
}
private void updateOpenWallets() {
updateOpenWallets(AppServices.get().getOpenWallets().keySet());
}
private void updateOpenWallets(Collection<Wallet> wallets) {
List<Wallet> openWalletList = wallets.stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet()).collect(Collectors.toList());
if(sendController.getPaymentTabs().getTabs().size() <= 1 && SorobanServices.canWalletMix(sendController.getWalletForm().getWallet())) {
openWalletList.add(payNymWallet);
}
openWallets.setItems(FXCollections.observableList(openWalletList));
}
private void addValidation(ValidationSupport validationSupport) {
this.validationSupport = validationSupport;
@@ -216,7 +295,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
private Address getRecipientAddress() throws InvalidAddressException {
return Address.fromString(address.getText());
return payNymProperty.get() == null ? Address.fromString(address.getText()) : new PayNymAddress(payNymProperty.get());
}
private Long getRecipientValueSats() {
@@ -336,6 +415,7 @@ public class PaymentController extends WalletFormController implements Initializ
setSendMax(false);
dustAmountProperty.set(false);
payNymProperty.set(null);
}
public void setMaxInput(ActionEvent event) {
@@ -443,4 +523,9 @@ public class PaymentController extends WalletFormController implements Initializ
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatAmount(event.getCurrencyRate(), getRecipientValueSats());
}
@Subscribe
public void openWallets(OpenWalletsEvent event) {
updateOpenWallets(event.getWallets());
}
}
@@ -4,7 +4,7 @@ import com.google.common.eventbus.Subscribe;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
@@ -19,6 +19,9 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.soroban.InitiatorDialog;
import com.sparrowwallet.sparrow.soroban.PayNymAddress;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
@@ -38,6 +41,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.Modality;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
@@ -162,6 +166,8 @@ public class SendController extends WalletFormController implements Initializabl
private final BooleanProperty includeSpentMempoolOutputsProperty = new SimpleBooleanProperty(false);
private final List<byte[]> opReturnsList = new ArrayList<>();
private final Set<WalletNode> excludedChangeNodes = new HashSet<>();
private final ChangeListener<String> feeListener = new ChangeListener<>() {
@@ -392,7 +398,7 @@ public class SendController extends WalletFormController implements Initializabl
transactionDiagram.update(walletTransaction);
updatePrivacyAnalysis(walletTransaction);
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymPayment(walletTransaction.getPayments()));
});
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
@@ -556,7 +562,7 @@ public class SendController extends WalletFormController implements Initializabl
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
boolean includeSpentMempoolOutputs = includeSpentMempoolOutputsProperty.get();
walletTransactionService = new WalletTransactionService(wallet, getUtxoSelectors(payments), getUtxoFilters(), payments, excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
walletTransactionService = new WalletTransactionService(wallet, getUtxoSelectors(payments), getUtxoFilters(), payments, opReturnsList, excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
walletTransactionService.setOnSucceeded(event -> {
if(!walletTransactionService.isIgnoreResult()) {
walletTransactionProperty.setValue(walletTransactionService.getValue());
@@ -603,7 +609,8 @@ public class SendController extends WalletFormController implements Initializabl
OptimizationStrategy optimizationStrategy = (OptimizationStrategy)optimizationToggleGroup.getSelectedToggle().getUserData();
if(optimizationStrategy == OptimizationStrategy.PRIVACY
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())) {
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())
&& !(payments.get(0).getAddress() instanceof PayNymAddress)) {
selectors.add(new StonewallUtxoSelector(noInputsFee));
}
@@ -616,6 +623,7 @@ public class SendController extends WalletFormController implements Initializabl
private final List<UtxoSelector> utxoSelectors;
private final List<UtxoFilter> utxoFilters;
private final List<Payment> payments;
private final List<byte[]> opReturns;
private final Set<WalletNode> excludedChangeNodes;
private final double feeRate;
private final double longTermFeeRate;
@@ -626,11 +634,12 @@ public class SendController extends WalletFormController implements Initializabl
private final boolean includeSpentMempoolOutputs;
private boolean ignoreResult;
public WalletTransactionService(Wallet wallet, List<UtxoSelector> utxoSelectors, List<UtxoFilter> utxoFilters, List<Payment> payments, Set<WalletNode> excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) {
public WalletTransactionService(Wallet wallet, List<UtxoSelector> utxoSelectors, List<UtxoFilter> utxoFilters, List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) {
this.wallet = wallet;
this.utxoSelectors = utxoSelectors;
this.utxoFilters = utxoFilters;
this.payments = payments;
this.opReturns = opReturns;
this.excludedChangeNodes = excludedChangeNodes;
this.feeRate = feeRate;
this.longTermFeeRate = longTermFeeRate;
@@ -645,7 +654,7 @@ public class SendController extends WalletFormController implements Initializabl
protected Task<WalletTransaction> createTask() {
return new Task<>() {
protected WalletTransaction call() throws InsufficientFundsException {
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, excludedChangeNodes, feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, excludedChangeNodes, feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
}
};
}
@@ -662,10 +671,10 @@ public class SendController extends WalletFormController implements Initializabl
private List<UtxoFilter> getUtxoFilters() {
UtxoFilter utxoFilter = utxoFilterProperty.get();
if(utxoFilter != null) {
return List.of(utxoFilter, new FrozenUtxoFilter());
return List.of(utxoFilter, new FrozenUtxoFilter(), new CoinbaseUtxoFilter(getWalletForm().getWallet()));
}
return List.of(new FrozenUtxoFilter());
return List.of(new FrozenUtxoFilter(), new CoinbaseUtxoFilter(getWalletForm().getWallet()));
}
private void updateFeeRateSelection(FeeRatesSelection feeRatesSelection) {
@@ -946,18 +955,34 @@ public class SendController extends WalletFormController implements Initializabl
}
}
private boolean isFakeMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null
private boolean isPayNymPayment(List<Payment> payments) {
return payments.size() == 1 && payments.get(0).getAddress() instanceof PayNymAddress;
}
public void setPayNymPayment() {
optimizationToggleGroup.selectToggle(privacyToggle);
transactionDiagram.setOptimizationStrategy(OptimizationStrategy.PRIVACY);
efficiencyToggle.setDisable(true);
privacyToggle.setDisable(false);
}
private boolean isMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null || SorobanServices.canWalletMix(walletForm.getWallet()))
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType()));
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
}
private void updateOptimizationButtons(List<Payment> payments) {
if(isFakeMixPossible(payments)) {
if(isPayNymPayment(payments)) {
setPayNymPayment();
} else if(isMixPossible(payments)) {
setPreferredOptimizationStrategy();
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(false);
} else {
optimizationToggleGroup.selectToggle(efficiencyToggle);
transactionDiagram.setOptimizationStrategy(OptimizationStrategy.EFFICIENCY);
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(true);
}
}
@@ -972,7 +997,9 @@ public class SendController extends WalletFormController implements Initializabl
}
private void setPreferredOptimizationStrategy() {
optimizationToggleGroup.selectToggle(getPreferredOptimizationStrategy() == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle);
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
optimizationToggleGroup.selectToggle(optimizationStrategy == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle);
transactionDiagram.setOptimizationStrategy(optimizationStrategy);
}
private void updatePrivacyAnalysis(WalletTransaction walletTransaction) {
@@ -1014,6 +1041,7 @@ public class SendController extends WalletFormController implements Initializabl
utxoSelectorProperty.setValue(null);
utxoFilterProperty.setValue(null);
includeSpentMempoolOutputsProperty.set(false);
opReturnsList.clear();
excludedChangeNodes.clear();
walletTransactionProperty.setValue(null);
createdWalletTransactionProperty.set(null);
@@ -1023,6 +1051,9 @@ public class SendController extends WalletFormController implements Initializabl
setInputFieldsDisabled(false);
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(false);
premixButton.setVisible(false);
createButton.setDefaultButton(true);
}
@@ -1078,23 +1109,24 @@ public class SendController extends WalletFormController implements Initializabl
}
public void createTransaction(ActionEvent event) {
WalletTransaction walletTransaction = walletTransactionProperty.get();
if(log.isDebugEnabled()) {
Map<WalletNode, List<String>> inputHashes = new LinkedHashMap<>();
for(WalletNode node : walletTransactionProperty.get().getSelectedUtxos().values()) {
for(WalletNode node : walletTransaction.getSelectedUtxos().values()) {
List<String> nodeHashes = inputHashes.computeIfAbsent(node, k -> new ArrayList<>());
nodeHashes.add(ElectrumServer.getScriptHash(walletForm.getWallet(), node));
}
Map<WalletNode, List<String>> changeHash = new LinkedHashMap<>();
for(WalletNode changeNode : walletTransactionProperty.get().getChangeMap().keySet()) {
for(WalletNode changeNode : walletTransaction.getChangeMap().keySet()) {
changeHash.put(changeNode, List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), changeNode)));
}
log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
log.debug("Creating tx " + walletTransaction.getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
}
addWalletTransactionNodes();
createdWalletTransactionProperty.set(walletTransactionProperty.get());
PSBT psbt = walletTransactionProperty.get().createPSBT();
EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransactionProperty.get().getPayments().get(0).getLabel(), null, psbt));
createdWalletTransactionProperty.set(walletTransaction);
PSBT psbt = walletTransaction.createPSBT();
EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransaction.getPayments().get(0).getLabel(), null, psbt));
}
private void addWalletTransactionNodes() {
@@ -1145,6 +1177,7 @@ public class SendController extends WalletFormController implements Initializabl
AppServices.showErrorDialog("Error broadcasting premix transaction", exception.getMessage());
});
ServiceProgressDialog progressDialog = new ServiceProgressDialog("Whirlpool", "Broadcast Premix Transaction", "/image/whirlpool.png", tx0BroadcastService);
AppServices.moveToActiveWindowScreen(progressDialog);
tx0BroadcastService.start();
}
@@ -1238,6 +1271,10 @@ public class SendController extends WalletFormController implements Initializabl
setPayments(List.of(payment));
}
if(event.getOpReturns() != null) {
opReturnsList.addAll(event.getOpReturns());
}
if(event.getFee() != null) {
setFeeValueSats(event.getFee());
userFeeSet.set(true);
@@ -1360,6 +1397,38 @@ public class SendController extends WalletFormController implements Initializabl
}
}
@Subscribe
public void sorobanInitiated(SorobanInitiatedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
InitiatorDialog initiatorDialog = new InitiatorDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), walletTransactionProperty.get());
if(Config.get().isSameAppMixing()) {
initiatorDialog.initModality(Modality.NONE);
}
Optional<Transaction> optTransaction = initiatorDialog.showAndWait();
if(optTransaction.isPresent()) {
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(optTransaction.get());
broadcastTransactionService.setOnRunning(workerStateEvent -> {
createButton.setDisable(true);
addWalletTransactionNodes();
});
broadcastTransactionService.setOnSucceeded(workerStateEvent -> {
createButton.setDisable(false);
clear(null);
});
broadcastTransactionService.setOnFailed(workerStateEvent -> {
createButton.setDisable(false);
Throwable exception = workerStateEvent.getSource().getException();
while(exception.getCause() != null) {
exception = exception.getCause();
}
AppServices.showErrorDialog("Error broadcasting mix transaction", exception.getMessage());
});
broadcastTransactionService.start();
}
}
}
private class PrivacyAnalysisTooltip extends VBox {
private List<Label> analysisLabels = new ArrayList<>();
@@ -1367,23 +1436,34 @@ public class SendController extends WalletFormController implements Initializabl
List<Payment> payments = walletTransaction.getPayments();
List<Payment> userPayments = payments.stream().filter(payment -> payment.getType() != Payment.Type.FAKE_MIX).collect(Collectors.toList());
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
boolean payNymPresent = isPayNymPayment(payments);
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().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
boolean addressReuse = userPayments.stream().anyMatch(payment -> getWalletForm().getWallet().getWalletAddresses().get(payment.getAddress()) != null && !getWalletForm().getWallet().getWalletAddresses().get(payment.getAddress()).getTransactionOutputs().isEmpty());
if(optimizationStrategy == OptimizationStrategy.PRIVACY) {
if(fakeMixPresent) {
if(payNymPresent) {
addLabel("Appears as a normal transaction, but actual value transferred is hidden", getPlusGlyph());
} else if(fakeMixPresent) {
addLabel("Appears as a two person coinjoin", getPlusGlyph());
} else {
if(mixedAddressTypes) {
addLabel("Cannot fake coinjoin due to mixed address types", getWarningGlyph());
} else if(utxoSelectorProperty().get() != null) {
addLabel("Cannot fake coinjoin due to coin control", getWarningGlyph());
addLabel("Cannot coinjoin due to mixed address types", getInfoGlyph());
} else if(userPayments.size() > 1) {
addLabel("Cannot fake coinjoin due to multiple payments", getWarningGlyph());
addLabel("Cannot coinjoin due to multiple payments", getInfoGlyph());
} else {
addLabel("Cannot fake coinjoin due to insufficient funds", getWarningGlyph());
if(utxoSelectorProperty().get() != null) {
addLabel("Cannot fake coinjoin due to coin control", getInfoGlyph());
} else {
addLabel("Cannot fake coinjoin due to insufficient funds", getInfoGlyph());
}
if(!SorobanServices.canWalletMix(getWalletForm().getWallet())) {
addLabel("Can only add mix partner to Native Segwit software wallets", getInfoGlyph());
} else {
addLabel("Add a mix partner to create a two person coinjoin", getInfoGlyph());
}
}
}
}
@@ -1392,7 +1472,7 @@ public class SendController extends WalletFormController implements Initializabl
addLabel("Address types different to the wallet indicate external payments", getMinusGlyph());
}
if(roundPaymentAmounts && !fakeMixPresent) {
if(roundPaymentAmounts && !fakeMixPresent && !payNymPresent) {
addLabel("Rounded payment amounts indicate external payments", getMinusGlyph());
}
@@ -1400,7 +1480,7 @@ public class SendController extends WalletFormController implements Initializabl
addLabel("Address reuse detected", getMinusGlyph());
}
if(analysisLabels.isEmpty() || (analysisLabels.size() == 1 && analysisLabels.get(0).getText().startsWith("Cannot fake coinjoin"))) {
if(!fakeMixPresent && !mixedAddressTypes && !roundPaymentAmounts) {
addLabel("Appears as a possible self transfer", getPlusGlyph());
}
@@ -1439,5 +1519,13 @@ public class SendController extends WalletFormController implements Initializabl
minusGlyph.setFontSize(12);
return minusGlyph;
}
private static Glyph getInfoGlyph() {
Glyph infoGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.INFO_CIRCLE);
infoGlyph.setUserData(3);
infoGlyph.setStyle("-fx-text-fill: -fx-accent");
infoGlyph.setFontSize(12);
return infoGlyph;
}
}
}
@@ -15,6 +15,7 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.StorageException;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Platform;
import javafx.beans.property.SimpleIntegerProperty;
@@ -359,6 +360,8 @@ public class SettingsController extends WalletFormController implements Initiali
return List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_SCRIPT_HASH);
} else if(scriptType == ScriptType.P2WSH) {
return List.of(ScriptExpression.WITNESS_SCRIPT_HASH);
} else if(scriptType == ScriptType.P2TR) {
return List.of(ScriptExpression.TAPROOT);
}
throw new IllegalArgumentException("Unknown script type of " + scriptType);
@@ -373,7 +376,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
public void editDescriptor(ActionEvent event) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet(), KeyPurpose.DEFAULT_PURPOSES, null);
String outputDescriptorString = outputDescriptor.toString(walletForm.getWallet().isValid());
TextAreaDialog dialog = new TextAreaDialog(outputDescriptorString);
@@ -405,7 +408,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
public void showDescriptor(ActionEvent event) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet(), KeyPurpose.DEFAULT_PURPOSES, null);
String outputDescriptorString = outputDescriptor.toString(walletForm.getWallet().isValid());
TextAreaDialog dialog = new TextAreaDialog(outputDescriptorString, false);
@@ -429,7 +432,8 @@ public class SettingsController extends WalletFormController implements Initiali
throw new IllegalStateException("Cannot export unsaved wallet");
}
Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile())).map(Map.Entry::getKey).findFirst();
Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream()
.filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile()) && entry.getKey().isMasterWallet()).map(Map.Entry::getKey).findFirst();
if(optWallet.isPresent()) {
Wallet wallet = optWallet.get();
if(!walletForm.getWallet().getName().equals(wallet.getName())) {
@@ -437,7 +441,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
if(wallet == null) {
throw new IllegalStateException("Cannot find child wallet " + walletForm.getWallet().getFullName() + " to export");
throw new IllegalStateException("Cannot find child wallet " + walletForm.getWallet().getFullDisplayName() + " to export");
}
WalletExportDialog dlg = new WalletExportDialog(wallet);
@@ -452,58 +456,127 @@ public class SettingsController extends WalletFormController implements Initiali
Wallet masterWallet = openWallet.isMasterWallet() ? openWallet : openWallet.getMasterWallet();
AddAccountDialog addAccountDialog = new AddAccountDialog(masterWallet);
Optional<StandardAccount> optAccount = addAccountDialog.showAndWait();
if(optAccount.isPresent()) {
StandardAccount standardAccount = optAccount.get();
Optional<List<StandardAccount>> optAccounts = addAccountDialog.showAndWait();
if(optAccounts.isPresent()) {
List<StandardAccount> standardAccounts = optAccounts.get();
if(addAccountDialog.isDiscoverAccounts() && !AppServices.isConnected()) {
return;
}
if(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)) {
if(masterWallet.isEncrypted()) {
String walletId = walletForm.getWalletId();
WalletPasswordDialog dlg = new WalletPasswordDialog(masterWallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(walletForm.getStorage(), password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), walletForm.getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
masterWallet.decrypt(key);
addAccounts(masterWallet, standardAccounts, addAccountDialog.isDiscoverAccounts());
}
}
try {
addAndSaveAccount(masterWallet, standardAccount);
} finally {
masterWallet.encrypt(key);
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isEncrypted()) {
childWallet.encrypt(key);
}
private void addAccounts(Wallet masterWallet, List<StandardAccount> standardAccounts, boolean discoverAccounts) {
if(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)) {
if(masterWallet.isEncrypted()) {
String walletId = walletForm.getWalletId();
WalletPasswordDialog dlg = new WalletPasswordDialog(masterWallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(walletForm.getStorage(), password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), walletForm.getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
encryptionFullKey.clear();
masterWallet.decrypt(key);
if(discoverAccounts) {
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(masterWallet, standardAccounts);
walletDiscoveryService.setOnSucceeded(event -> {
addAndEncryptAccounts(masterWallet, walletDiscoveryService.getValue(), key);
if(walletDiscoveryService.getValue().isEmpty()) {
AppServices.showAlertDialog("No Accounts Found", "No new accounts with existing transactions were found. Note only the first 10 accounts are scanned.", Alert.AlertType.INFORMATION, ButtonType.OK);
}
key.clear();
encryptionFullKey.clear();
password.get().clear();
});
walletDiscoveryService.setOnFailed(event -> {
log.error("Failed to discover accounts", event.getSource().getException());
addAndEncryptAccounts(masterWallet, Collections.emptyList(), key);
AppServices.showErrorDialog("Failed to discover accounts", event.getSource().getException().getMessage());
});
walletDiscoveryService.start();
} else {
addAndEncryptAccounts(masterWallet, standardAccounts, key);
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> addAccount(null));
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> addAccount(null));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
addAndSaveAccount(masterWallet, standardAccount);
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
Wallet childWallet = masterWallet.addChildWallet(standardAccount);
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
if(discoverAccounts) {
ElectrumServer.WalletDiscoveryService walletDiscoveryService = new ElectrumServer.WalletDiscoveryService(masterWallet, standardAccounts);
walletDiscoveryService.setOnSucceeded(event -> {
addAndSaveAccounts(masterWallet, walletDiscoveryService.getValue());
if(walletDiscoveryService.getValue().isEmpty()) {
AppServices.showAlertDialog("No Accounts Found", "No new accounts with existing transactions were found. Note only the first 10 accounts are scanned.", Alert.AlertType.INFORMATION, ButtonType.OK);
}
});
walletDiscoveryService.setOnFailed(event -> {
log.error("Failed to discover accounts", event.getSource().getException());
AppServices.showErrorDialog("Failed to discover accounts", event.getSource().getException().getMessage());
});
walletDiscoveryService.start();
} else {
addAndSaveAccounts(masterWallet, standardAccounts);
}
}
} else {
if(discoverAccounts && masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)) {
String fingerprint = masterWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint();
DeviceKeystoreDiscoverDialog deviceKeystoreDiscoverDialog = new DeviceKeystoreDiscoverDialog(List.of(fingerprint), masterWallet, standardAccounts);
Optional<Map<StandardAccount, Keystore>> optDiscoveredKeystores = deviceKeystoreDiscoverDialog.showAndWait();
if(optDiscoveredKeystores.isPresent()) {
Map<StandardAccount, Keystore> discoveredKeystores = optDiscoveredKeystores.get();
if(discoveredKeystores.isEmpty()) {
AppServices.showAlertDialog("No Accounts Found", "No new accounts with existing transactions were found. Note only the first 10 accounts are scanned.", Alert.AlertType.INFORMATION, ButtonType.OK);
} else {
for(Map.Entry<StandardAccount, Keystore> entry : discoveredKeystores.entrySet()) {
Wallet childWallet = masterWallet.addChildWallet(entry.getKey());
childWallet.getKeystores().clear();
childWallet.getKeystores().add(entry.getValue());
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
saveChildWallets(masterWallet);
}
}
} else {
for(StandardAccount standardAccount : standardAccounts) {
Wallet childWallet = masterWallet.addChildWallet(standardAccount);
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
}
}
}
private void addAndEncryptAccounts(Wallet masterWallet, List<StandardAccount> standardAccounts, Key key) {
try {
addAndSaveAccounts(masterWallet, standardAccounts);
} finally {
masterWallet.encrypt(key);
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isEncrypted()) {
childWallet.encrypt(key);
}
}
key.clear();
}
}
private void addAndSaveAccounts(Wallet masterWallet, List<StandardAccount> standardAccounts) {
for(StandardAccount standardAccount : standardAccounts) {
addAndSaveAccount(masterWallet, standardAccount);
}
}
@@ -515,12 +588,17 @@ public class SettingsController extends WalletFormController implements Initiali
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
saveChildWallets(masterWallet);
}
private void saveChildWallets(Wallet masterWallet) {
for(Wallet childWallet : masterWallet.getChildWallets()) {
Storage storage = AppServices.get().getOpenWallets().get(childWallet);
if(!storage.isPersisted(childWallet)) {
try {
storage.saveWallet(childWallet);
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage());
}
}
@@ -102,19 +102,10 @@ public class TransactionsController extends WalletFormController implements Init
});
transactionsMasterDetail.setShowDetailNode(Config.get().isShowLoadingLog());
loadingLog.appendText("Wallet loading history for " + getWalletForm().getWallet().getFullName());
loadingLog.appendText("Wallet loading history for " + getWalletForm().getWallet().getFullDisplayName());
loadingLog.setEditable(false);
}
private void setFiatBalance(FiatLabel fiatLabel, CurrencyRate currencyRate, long balance) {
if(currencyRate != null && currencyRate.isAvailable() && balance > 0) {
fiatLabel.set(currencyRate, balance);
} else {
fiatLabel.setCurrency(null);
fiatLabel.setBtcRate(0.0);
}
}
private void setTransactionCount(WalletTransactionsEntry walletTransactionsEntry) {
transactionCount.setText(walletTransactionsEntry.getChildren() != null ? Integer.toString(walletTransactionsEntry.getChildren().size()) : "0");
}
@@ -4,6 +4,7 @@ import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.mix.listener.MixStep;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
@@ -127,6 +128,7 @@ public class UtxoEntry extends HashIndexEntry {
private Utxo nextMixUtxo;
private MixFailReason mixFailReason;
private String mixError;
private Long mixErrorTimestamp;
public MixStatus(MixProgress mixProgress) {
this.mixProgress = mixProgress;
@@ -139,6 +141,7 @@ public class UtxoEntry extends HashIndexEntry {
public MixStatus(MixFailReason mixFailReason, String mixError) {
this.mixFailReason = mixFailReason;
this.mixError = mixError;
this.mixErrorTimestamp = System.currentTimeMillis();
}
public UtxoEntry getUtxoEntry() {
@@ -151,12 +154,12 @@ public class UtxoEntry extends HashIndexEntry {
return wallet.getUtxoMixData(getHashIndex());
}
//Mix data not available - recount (and store if WhirlpoolWallet is running)
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(wallet);
if(whirlpool != null) {
UtxoMixData utxoMixData = whirlpool.getMixData(getHashIndex());
if(utxoMixData != null) {
return utxoMixData;
}
if(whirlpool != null && getUtxoEntry().getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && node.getKeyPurpose() == KeyPurpose.RECEIVE) {
int mixesDone = whirlpool.recountMixesDone(getUtxoEntry().getWallet(), getHashIndex());
whirlpool.setMixesDone(getHashIndex(), mixesDone);
return new UtxoMixData(mixesDone, null);
}
return new UtxoMixData(getUtxoEntry().getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX ? 1 : 0, null);
@@ -181,5 +184,9 @@ public class UtxoEntry extends HashIndexEntry {
public String getMixError() {
return mixError;
}
public Long getMixErrorTimestamp() {
return mixErrorTimestamp;
}
}
}
@@ -5,7 +5,6 @@ import com.google.common.eventbus.Subscribe;
import com.samourai.whirlpool.client.tx0.Tx0Preview;
import com.sparrowwallet.drongo.BitcoinUnit;
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;
@@ -53,6 +52,21 @@ import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class UtxosController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(UtxosController.class);
@FXML
private CoinLabel balance;
@FXML
private FiatLabel fiatBalance;
@FXML
private CoinLabel mempoolBalance;
@FXML
private FiatLabel fiatMempoolBalance;
@FXML
private CopyableLabel utxoCount;
@FXML
private UtxosTreeTable utxosTable;
@@ -116,8 +130,17 @@ public class UtxosController extends WalletFormController implements Initializab
@Override
public void initializeView() {
utxosTable.initialize(getWalletForm().getWalletUtxosEntry());
utxosChart.initialize(getWalletForm().getWalletUtxosEntry());
balance.valueProperty().addListener((observable, oldValue, newValue) -> {
setFiatBalance(fiatBalance, AppServices.getFiatCurrencyExchangeRate(), newValue.longValue());
});
mempoolBalance.valueProperty().addListener((observable, oldValue, newValue) -> {
setFiatBalance(fiatMempoolBalance, AppServices.getFiatCurrencyExchangeRate(), newValue.longValue());
});
WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry();
updateFields(walletUtxosEntry);
utxosTable.initialize(walletUtxosEntry);
utxosChart.initialize(walletUtxosEntry);
mixButtonsBox.managedProperty().bind(mixButtonsBox.visibleProperty());
mixButtonsBox.setVisible(getWalletForm().getWallet().isWhirlpoolMixWallet());
@@ -135,7 +158,13 @@ public class UtxosController extends WalletFormController implements Initializab
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet());
if(whirlpool != null) {
stopMix.visibleProperty().bind(whirlpool.mixingProperty());
if(whirlpool.startingProperty().getValue()) {
mixingStartingListener.changed(whirlpool.startingProperty(), null, whirlpool.startingProperty().getValue());
}
whirlpool.startingProperty().addListener(new WeakChangeListener<>(mixingStartingListener));
if(whirlpool.stoppingProperty().getValue()) {
mixingStoppingListener.changed(whirlpool.stoppingProperty(), null, whirlpool.stoppingProperty().getValue());
}
whirlpool.stoppingProperty().addListener(new WeakChangeListener<>(mixingStoppingListener));
whirlpool.mixingProperty().addListener(new WeakChangeListener<>(mixingListener));
updateMixToButton();
@@ -154,9 +183,12 @@ public class UtxosController extends WalletFormController implements Initializab
utxosChart.select(selectedEntries);
updateButtons(Config.get().getBitcoinUnit());
});
}
utxosChart.managedProperty().bind(utxosChart.visibleProperty());
utxosChart.setVisible(Config.get().isShowUtxosChart() && !getWalletForm().getWallet().isWhirlpoolMixWallet());
private void updateFields(WalletUtxosEntry walletUtxosEntry) {
balance.setValue(walletUtxosEntry.getChildren().stream().mapToLong(Entry::getValue).sum());
mempoolBalance.setValue(walletUtxosEntry.getChildren().stream().filter(entry -> ((UtxoEntry)entry).getHashIndex().getHeight() <= 0).mapToLong(Entry::getValue).sum());
utxoCount.setText(walletUtxosEntry.getChildren() != null ? Integer.toString(walletUtxosEntry.getChildren().size()) : "0");
}
private boolean canWalletMix() {
@@ -193,9 +225,11 @@ public class UtxosController extends WalletFormController implements Initializab
if(mixConfig != null && mixConfig.getMixToWalletName() != null) {
mixTo.setText("Mix to " + mixConfig.getMixToWalletName());
try {
AppServices.getWhirlpoolServices().getWhirlpoolMixToWalletId(mixConfig);
String mixToWalletId = AppServices.getWhirlpoolServices().getWhirlpoolMixToWalletId(mixConfig);
String mixToName = AppServices.get().getWallet(mixToWalletId).getFullDisplayName();
mixTo.setText("Mix to " + mixToName);
mixTo.setGraphic(getExternalGlyph());
mixTo.setTooltip(new Tooltip("Mixing to " + mixConfig.getMixToWalletName() + " after at least " + (mixConfig.getMinMixes() == null ? Whirlpool.DEFAULT_MIXTO_MIN_MIXES : mixConfig.getMinMixes()) + " mixes"));
mixTo.setTooltip(new Tooltip("Mixing to " + mixToName + " after at least " + (mixConfig.getMinMixes() == null ? Whirlpool.DEFAULT_MIXTO_MIN_MIXES : mixConfig.getMinMixes()) + " mixes"));
} catch(NoSuchElementException e) {
mixTo.setGraphic(getErrorGlyph());
mixTo.setTooltip(new Tooltip(mixConfig.getMixToWalletName() + " is not open - open this wallet to mix to it!"));
@@ -224,7 +258,7 @@ public class UtxosController extends WalletFormController implements Initializab
public void mixSelected(ActionEvent event) {
List<UtxoEntry> selectedEntries = getSelectedUtxos();
WhirlpoolDialog whirlpoolDialog = new WhirlpoolDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), selectedEntries);
WhirlpoolDialog whirlpoolDialog = new WhirlpoolDialog(getWalletForm().getMasterWalletId(), getWalletForm().getWallet(), selectedEntries);
Optional<Tx0Preview> optTx0Preview = whirlpoolDialog.showAndWait();
optTx0Preview.ifPresent(tx0Preview -> previewPremix(tx0Preview, selectedEntries));
}
@@ -314,10 +348,12 @@ public class UtxosController extends WalletFormController implements Initializab
payments.add(new Payment(premixAddress, "Premix #" + i, tx0Preview.getPremixValue(), false));
}
List<byte[]> opReturns = List.of(new byte[64]);
final List<BlockTransactionHashIndex> utxos = utxoEntries.stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList());
Platform.runLater(() -> {
EventManager.get().post(new SendActionEvent(getWalletForm().getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(getWalletForm().getWallet(), utxos, payments, tx0Preview.getTx0MinerFee(), tx0Preview.getPool())));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(getWalletForm().getWallet(), utxos, payments, opReturns, tx0Preview.getTx0MinerFee(), tx0Preview.getPool())));
});
}
@@ -373,9 +409,11 @@ public class UtxosController extends WalletFormController implements Initializab
mixConfig.setMixToWalletName(changedMixConfig.getMixToWalletName());
mixConfig.setMixToWalletFile(changedMixConfig.getMixToWalletFile());
mixConfig.setMinMixes(changedMixConfig.getMinMixes());
mixConfig.setIndexRange(changedMixConfig.getIndexRange());
EventManager.get().post(new WalletMasterMixConfigChangedEvent(getWalletForm().getWallet()));
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWalletForm().getWallet());
whirlpool.setPostmixIndexRange(mixConfig.getIndexRange());
try {
String mixToWalletId = AppServices.getWhirlpoolServices().getWhirlpoolMixToWalletId(mixConfig);
whirlpool.setMixToWallet(mixToWalletId, mixConfig.getMinMixes());
@@ -447,6 +485,7 @@ public class UtxosController extends WalletFormController implements Initializab
public void walletNodesChanged(WalletNodesChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry();
updateFields(walletUtxosEntry);
utxosTable.updateAll(walletUtxosEntry);
utxosChart.update(walletUtxosEntry);
mixSelected.setVisible(canWalletMix());
@@ -467,6 +506,7 @@ public class UtxosController extends WalletFormController implements Initializab
utxosTable.getSelectionModel().clearSelection();
}
updateFields(walletUtxosEntry);
utxosTable.updateHistory(event.getHistoryChangedNodes());
utxosChart.update(walletUtxosEntry);
}
@@ -486,6 +526,8 @@ public class UtxosController extends WalletFormController implements Initializab
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
utxosTable.setBitcoinUnit(getWalletForm().getWallet(), event.getBitcoinUnit());
utxosChart.setBitcoinUnit(getWalletForm().getWallet(), event.getBitcoinUnit());
balance.refresh(event.getBitcoinUnit());
mempoolBalance.refresh(event.getBitcoinUnit());
updateButtons(event.getBitcoinUnit());
}
@@ -494,6 +536,10 @@ public class UtxosController extends WalletFormController implements Initializab
utxosTable.updateHistoryStatus(event);
}
@Subscribe
public void newBlock(NewBlockEvent event) {
getWalletForm().getWalletUtxosEntry().updateMixProgress();
}
@Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) {
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.ViewPasswordField;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
@@ -147,7 +148,7 @@ public class WalletController extends WalletFormController implements Initializa
Label label = new Label("Enter password to unlock:");
label.managedProperty().bind(label.visibleProperty());
label.visibleProperty().bind(walletEncryptedProperty);
CustomPasswordField passwordField = (CustomPasswordField)TextFields.createClearablePasswordField();
CustomPasswordField passwordField = new ViewPasswordField();
passwordField.setMaxWidth(300);
passwordField.managedProperty().bind(passwordField.visibleProperty());
passwordField.visibleProperty().bind(walletEncryptedProperty);
@@ -10,6 +10,7 @@ import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.StorageException;
import com.sparrowwallet.sparrow.net.AllHistoryChangedException;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -25,6 +26,8 @@ import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.drongo.wallet.WalletNode.nodeRangesToString;
public class WalletForm {
private static final Logger log = LoggerFactory.getLogger(WalletForm.class);
@@ -66,6 +69,10 @@ public class WalletForm {
return wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
}
public String getMasterWalletId() {
return storage.getWalletId(getMasterWallet());
}
public Storage getStorage() {
return storage;
}
@@ -119,11 +126,14 @@ public class WalletForm {
refreshHistory(blockHeight, pastWallet, null);
}
public void refreshHistory(Integer blockHeight, Wallet pastWallet, WalletNode node) {
public void refreshHistory(Integer blockHeight, Wallet pastWallet, Set<WalletNode> nodes) {
Wallet previousWallet = wallet.copy();
if(wallet.isValid() && AppServices.isConnected()) {
log.debug(node == null ? wallet.getFullName() + " refreshing full wallet history" : wallet.getFullName() + " requesting node wallet history for " + node.getDerivationPath());
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(node));
if(log.isDebugEnabled()) {
log.debug(nodes == null ? wallet.getFullName() + " refreshing full wallet history" : wallet.getFullName() + " requesting node wallet history for " + nodeRangesToString(nodes));
}
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(nodes));
historyService.setOnSucceeded(workerStateEvent -> {
if(historyService.getValue()) {
EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
@@ -131,16 +141,28 @@ public class WalletForm {
}
});
historyService.setOnFailed(workerStateEvent -> {
if(AppServices.isConnected()) {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
} else {
log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException());
}
if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) {
try {
storage.backupWallet();
} catch(IOException e) {
log.error("Error backing up wallet", e);
}
EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, getWalletId()));
} else {
if(AppServices.isConnected()) {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
} else {
log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException());
}
EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
}
});
EventManager.get().post(new WalletHistoryStartedEvent(wallet, node));
EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes));
historyService.start();
}
}
@@ -186,7 +208,9 @@ public class WalletForm {
}
}
storage.deleteTempBackups();
//Force saving the backup if the current wallet has fewer transactions than the past wallet (i.e. incomplete load)
storage.deleteTempBackups(wallet.getTransactions().size() < pastWallet.getTransactions().size());
return changedEntries;
}
@@ -245,19 +269,21 @@ public class WalletForm {
walletTransactionNodes.add(transactionNodes);
}
private Set<WalletNode> getWalletTransactionNodes(WalletNode walletNode) {
if(walletNode == null) {
private Set<WalletNode> getWalletTransactionNodes(Set<WalletNode> walletNodes) {
if(walletNodes == null) {
return null;
}
Set<WalletNode> allNodes = new LinkedHashSet<>();
for(Set<WalletNode> nodes : walletTransactionNodes) {
if(nodes.contains(walletNode)) {
allNodes.addAll(nodes);
for(WalletNode walletNode : walletNodes) {
for(Set<WalletNode> nodes : walletTransactionNodes) {
if(nodes.contains(walletNode)) {
allNodes.addAll(nodes);
}
}
}
return allNodes.isEmpty() ? Set.of(walletNode) : allNodes;
return allNodes.isEmpty() ? walletNodes : allNodes;
}
public NodeEntry getNodeEntry(KeyPurpose keyPurpose) {
@@ -306,6 +332,10 @@ public class WalletForm {
return walletUtxosEntry;
}
public boolean isLocked() {
return lockedProperty.get();
}
public BooleanProperty lockedProperty() {
return lockedProperty;
}
@@ -388,7 +418,7 @@ public class WalletForm {
WalletNode walletNode = event.getWalletNode(wallet);
if(walletNode != null) {
log.debug(wallet.getFullName() + " history event for node " + walletNode + " (" + event.getScriptHash() + ")");
refreshHistory(AppServices.getCurrentBlockHeight(), null, walletNode);
refreshHistory(AppServices.getCurrentBlockHeight(), null, Set.of(walletNode));
}
}
}
@@ -499,6 +529,33 @@ public class WalletForm {
}
}
@Subscribe
public void walletLabelChanged(WalletLabelChangedEvent event) {
if(event.getWallet() == wallet) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
@Subscribe
public void walletGapLimitChanged(WalletGapLimitChangedEvent event) {
if(event.getWallet() == wallet) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
Set<WalletNode> newNodes = new LinkedHashSet<>();
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
Optional<WalletNode> optPurposeNode = wallet.getPurposeNodes().stream().filter(node -> node.getKeyPurpose() == keyPurpose).findFirst();
if(optPurposeNode.isPresent()) {
WalletNode purposeNode = optPurposeNode.get();
newNodes.addAll(purposeNode.fillToIndex(wallet.getLookAheadIndex(purposeNode)));
}
}
if(!newNodes.isEmpty()) {
Platform.runLater(() -> refreshHistory(AppServices.getCurrentBlockHeight(), null, newNodes));
}
}
}
@Subscribe
public void whirlpoolMixSuccess(WhirlpoolMixSuccessEvent event) {
if(event.getWallet() == wallet && event.getWalletNode() != null) {
@@ -5,8 +5,10 @@ import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.BaseController;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.control.FiatLabel;
import com.sparrowwallet.sparrow.event.WalletTabsClosedEvent;
public abstract class WalletFormController extends BaseController {
@@ -53,4 +55,13 @@ public abstract class WalletFormController extends BaseController {
return node.getDerivationPath().replace("m", "multi");
}
protected void setFiatBalance(FiatLabel fiatLabel, CurrencyRate currencyRate, long balance) {
if(currencyRate != null && currencyRate.isAvailable() && balance > 0) {
fiatLabel.set(currencyRate, balance);
} else {
fiatLabel.setCurrency(null);
fiatLabel.setBtcRate(0.0);
}
}
}
@@ -93,7 +93,8 @@ public class WalletTransactionsEntry extends Entry {
private static void getWalletTransactions(Wallet wallet, Map<BlockTransaction, WalletTransaction> walletTransactionMap, WalletNode purposeNode) {
KeyPurpose keyPurpose = purposeNode.getKeyPurpose();
for(WalletNode addressNode : purposeNode.getChildren()) {
List<WalletNode> childNodes = new ArrayList<>(purposeNode.getChildren());
for(WalletNode addressNode : childNodes) {
for(BlockTransactionHashIndex hashIndex : addressNode.getTransactionOutputs()) {
BlockTransaction inputTx = wallet.getTransactions().get(hashIndex.getHash());
//A null inputTx here means the wallet is still updating - ignore as the WalletHistoryChangedEvent will run this again
@@ -12,7 +12,7 @@ public class WalletUtxosEntry extends Entry {
public WalletUtxosEntry(Wallet wallet) {
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
calculateDuplicates();
retrieveMixProgress();
updateMixProgress();
}
@Override
@@ -38,13 +38,15 @@ public class WalletUtxosEntry extends Entry {
}
}
protected void retrieveMixProgress() {
public void updateMixProgress() {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWallet());
if(whirlpool != null) {
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
MixProgress mixProgress = whirlpool.getMixProgress(utxoEntry.getHashIndex());
utxoEntry.setMixProgress(mixProgress);
if(mixProgress != null || utxoEntry.getMixStatus() == null || (utxoEntry.getMixStatus().getMixFailReason() == null && utxoEntry.getMixStatus().getNextMixUtxo() == null)) {
utxoEntry.setMixProgress(mixProgress);
}
}
}
}
@@ -62,6 +64,6 @@ public class WalletUtxosEntry extends Entry {
getChildren().removeAll(entriesRemoved);
calculateDuplicates();
retrieveMixProgress();
updateMixProgress();
}
}
@@ -25,18 +25,15 @@ import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import com.sparrowwallet.nightjar.stomp.JavaStompClientService;
import com.sparrowwallet.nightjar.tor.WhirlpoolTorClientService;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WhirlpoolMixEvent;
import com.sparrowwallet.sparrow.event.WhirlpoolMixSuccessEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import com.sparrowwallet.sparrow.whirlpool.dataPersister.SparrowDataPersister;
import com.sparrowwallet.sparrow.whirlpool.dataSource.SparrowDataSource;
@@ -49,20 +46,18 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.util.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.*;
import java.util.stream.Collectors;
public class Whirlpool {
private static final Logger log = LoggerFactory.getLogger(Whirlpool.class);
public static final List<Network> WHIRLPOOL_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
public static final int DEFAULT_MIXTO_MIN_MIXES = 5;
public static final int DEFAULT_MIXTO_MIN_MIXES = 3;
public static final int DEFAULT_MIXTO_RANDOM_FACTOR = 4;
private final WhirlpoolServer whirlpoolServer;
@@ -71,12 +66,14 @@ public class Whirlpool {
private final TorClientService torClientService;
private final WhirlpoolWalletService whirlpoolWalletService;
private final WhirlpoolWalletConfig config;
private Tx0ParamService tx0ParamService;
private ExpirablePoolSupplier poolSupplier;
private Tx0Service tx0Service;
private final Tx0ParamService tx0ParamService;
private final ExpirablePoolSupplier poolSupplier;
private final Tx0Service tx0Service;
private Tx0FeeTarget tx0FeeTarget = Tx0FeeTarget.BLOCKS_4;
private HD_Wallet hdWallet;
private String walletId;
private String mixToWalletId;
private boolean resyncMixesDone;
private StartupService startupService;
@@ -100,7 +97,7 @@ public class Whirlpool {
}
private WhirlpoolWalletConfig computeWhirlpoolWalletConfig(HostAndPort torProxy) {
DataPersisterFactory dataPersisterFactory = (whirlpoolWallet, bip44w) -> new SparrowDataPersister(whirlpoolWallet);
DataPersisterFactory dataPersisterFactory = (whirlpoolWallet, bip44w) -> new SparrowDataPersister(whirlpoolWallet, config.getPersistDelaySeconds());
DataSourceFactory dataSourceFactory = (whirlpoolWallet, bip44w, dataPersister) -> new SparrowDataSource(whirlpoolWallet, bip44w, dataPersister);
boolean onion = (torProxy != null);
@@ -110,9 +107,20 @@ public class Whirlpool {
WhirlpoolWalletConfig whirlpoolWalletConfig = new WhirlpoolWalletConfig(dataSourceFactory, httpClientService, stompClientService, torClientService, serverApi, whirlpoolServer.getParams(), false);
whirlpoolWalletConfig.setDataPersisterFactory(dataPersisterFactory);
whirlpoolWalletConfig.setPartner("SPARROW");
whirlpoolWalletConfig.setIndexRangePostmix(IndexRange.FULL);
return whirlpoolWalletConfig;
}
public Pool getPool(String poolId) {
try {
return getPools(null).stream().filter(pool -> pool.getPoolId().equals(poolId)).findFirst().orElse(null);
} catch(Exception e) {
log.error("Error retrieving pools", e);
}
return null;
}
public Collection<Pool> getPools(Long totalUtxoValue) throws Exception {
this.poolSupplier.load();
if(totalUtxoValue == null) {
@@ -143,7 +151,6 @@ public class Whirlpool {
}
private Tx0Config computeTx0Config() {
Tx0FeeTarget tx0FeeTarget = Tx0FeeTarget.BLOCKS_4;
Tx0FeeTarget mixFeeTarget = Tx0FeeTarget.BLOCKS_4;
return new Tx0Config(tx0ParamService, poolSupplier, tx0FeeTarget, mixFeeTarget, WhirlpoolAccount.BADBANK);
}
@@ -162,7 +169,7 @@ public class Whirlpool {
HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance();
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
this.walletId = walletId;
hdWallet = new HD_Wallet(purpose, words, config.getNetworkParameters(), seed, passphrase, 1);
hdWallet = new HD_Wallet(purpose, words, config.getNetworkParameters(), seed, passphrase);
} catch(Exception e) {
throw new IllegalStateException("Could not create Whirlpool HD wallet ", e);
}
@@ -236,18 +243,99 @@ public class Whirlpool {
WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex());
if(whirlpoolUtxo != null && whirlpoolUtxo.getUtxoState() != null) {
return whirlpoolUtxo.getUtxoState().getMixProgress();
MixProgress mixProgress = whirlpoolUtxo.getUtxoState().getMixProgress();
if(mixProgress != null && !isMixing(utxo)) {
log.debug("Utxo " + utxo + " mix state is " + whirlpoolUtxo.getUtxoState() + " but utxo is not mixing");
return null;
}
return mixProgress;
}
return null;
}
private boolean isMixing(BlockTransactionHashIndex utxo) {
if(whirlpoolWalletService.whirlpoolWallet() == null || !whirlpoolWalletService.whirlpoolWallet().isStarted()) {
return false;
}
return whirlpoolWalletService.whirlpoolWallet().getMixingState().getUtxosMixing().stream().map(WhirlpoolUtxo::getUtxo).anyMatch(uo -> uo.tx_hash.equals(utxo.getHashAsString()) && uo.tx_output_n == (int)utxo.getIndex());
}
public void refreshUtxos() {
if(whirlpoolWalletService.whirlpoolWallet() != null) {
whirlpoolWalletService.whirlpoolWallet().refreshUtxos();
}
}
private void resyncMixesDone(Whirlpool whirlpool, Wallet postmixWallet) {
Set<BlockTransactionHashIndex> receiveUtxos = postmixWallet.getWalletUtxos().entrySet().stream()
.filter(entry -> entry.getValue().getKeyPurpose() == KeyPurpose.RECEIVE).map(Map.Entry::getKey).collect(Collectors.toSet());
for(BlockTransactionHashIndex utxo : receiveUtxos) {
int mixesDone = recountMixesDone(postmixWallet, utxo);
whirlpool.setMixesDone(utxo, mixesDone);
}
}
public int recountMixesDone(Wallet postmixWallet, BlockTransactionHashIndex postmixUtxo) {
int mixesDone = 0;
Set<BlockTransactionHashIndex> walletTxos = postmixWallet.getWalletTxos().entrySet().stream()
.filter(entry -> entry.getValue().getKeyPurpose() == KeyPurpose.RECEIVE).map(Map.Entry::getKey).collect(Collectors.toSet());
BlockTransaction blkTx = postmixWallet.getTransactions().get(postmixUtxo.getHash());
while(blkTx != null) {
mixesDone++;
List<TransactionInput> inputs = blkTx.getTransaction().getInputs();
blkTx = null;
for(TransactionInput txInput : inputs) {
BlockTransaction inputTx = postmixWallet.getTransactions().get(txInput.getOutpoint().getHash());
if(inputTx != null && walletTxos.stream().anyMatch(txo -> txo.getHash().equals(inputTx.getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()) && inputTx.getTransaction() != null) {
blkTx = inputTx;
}
}
}
return mixesDone;
}
public void setMixesDone(BlockTransactionHashIndex utxo, int mixesDone) {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
return;
}
WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex());
if(whirlpoolUtxo != null) {
whirlpoolUtxo.setMixsDone(mixesDone);
}
}
public void checkIfMixing() {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
return;
}
if(isMixing()) {
if(!whirlpoolWalletService.whirlpoolWallet().isStarted()) {
log.warn("Wallet is not started, but mixingProperty is true");
WhirlpoolEventService.getInstance().post(new WalletStopEvent(whirlpoolWalletService.whirlpoolWallet()));
} else if(whirlpoolWalletService.whirlpoolWallet().getMixingState().getUtxosMixing().isEmpty()) {
log.warn("No UTXOs mixing, but mixingProperty is true");
//Will automatically restart
AppServices.getWhirlpoolServices().stopWhirlpool(this, false);
}
}
}
public void logDebug() {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
log.warn("Whirlpool wallet for " + walletId + " not started");
return;
}
log.warn("Whirlpool debug for " + walletId + "\n" + whirlpoolWalletService.whirlpoolWallet().getDebug());
}
public boolean hasWallet() {
return hdWallet != null;
}
@@ -297,9 +385,15 @@ public class Whirlpool {
public static Wallet getStandardAccountWallet(WhirlpoolAccount whirlpoolAccount, Wallet wallet) {
StandardAccount standardAccount = getStandardAccount(whirlpoolAccount);
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) {
wallet = wallet.getChildWallet(standardAccount);
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount) || wallet.getStandardAccountType() != standardAccount) {
Wallet standardWallet = wallet.getChildWallet(standardAccount);
if(standardWallet == null) {
throw new IllegalStateException("Cannot find " + standardAccount + " wallet");
}
return standardWallet;
}
return wallet;
}
@@ -370,6 +464,10 @@ public class Whirlpool {
config.setServerApi(serverApi);
}
public void refreshTorCircuits() {
torClientService.changeIdentity();
}
public String getScode() {
return config.getScode();
}
@@ -378,6 +476,10 @@ public class Whirlpool {
config.setScode(scode);
}
public void setTx0FeeTarget(Tx0FeeTarget tx0FeeTarget) {
this.tx0FeeTarget = tx0FeeTarget;
}
public String getWalletId() {
return walletId;
}
@@ -386,6 +488,20 @@ public class Whirlpool {
return mixToWalletId;
}
public void setResyncMixesDone(boolean resyncMixesDone) {
this.resyncMixesDone = resyncMixesDone;
}
public void setPostmixIndexRange(String indexRange) {
if(indexRange != null) {
try {
config.setIndexRangePostmix(IndexRange.valueOf(indexRange));
} catch(Exception e) {
log.error("Invalid index range " + indexRange);
}
}
}
public void setMixToWallet(String mixToWalletId, Integer minMixes) {
if(mixToWalletId == null) {
config.setExternalDestination(null);
@@ -473,6 +589,15 @@ public class Whirlpool {
if(e.getWhirlpoolWallet() == whirlpoolWalletService.whirlpoolWallet()) {
log.info("Mixing to " + e.getWhirlpoolWallet().getConfig().getExternalDestination());
mixingProperty.set(true);
if(resyncMixesDone) {
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet != null) {
Wallet postmixWallet = getStandardAccountWallet(WhirlpoolAccount.POSTMIX, wallet);
resyncMixesDone(this, postmixWallet);
resyncMixesDone = false;
}
}
}
}
@@ -575,14 +700,17 @@ public class Whirlpool {
updateProgress(-1, 1);
updateMessage("Starting Whirlpool...");
whirlpool.startingProperty.set(true);
WhirlpoolWallet whirlpoolWallet = whirlpool.getWhirlpoolWallet();
if(AppServices.onlineProperty().get()) {
whirlpoolWallet.start();
}
whirlpool.startingProperty.set(false);
try {
whirlpool.startingProperty.set(true);
WhirlpoolWallet whirlpoolWallet = whirlpool.getWhirlpoolWallet();
if(AppServices.onlineProperty().get()) {
whirlpoolWallet.start();
}
return whirlpoolWallet;
return whirlpoolWallet;
} finally {
whirlpool.startingProperty.set(false);
}
}
};
}
@@ -601,10 +729,38 @@ public class Whirlpool {
protected Boolean call() throws Exception {
updateProgress(-1, 1);
updateMessage("Disconnecting from Whirlpool...");
whirlpool.stoppingProperty.set(true);
whirlpool.shutdown();
whirlpool.stoppingProperty.set(false);
return true;
try {
whirlpool.stoppingProperty.set(true);
whirlpool.shutdown();
return true;
} finally {
whirlpool.stoppingProperty.set(false);
}
}
};
}
}
public static class RegisteredInputsService extends Service<Integer> {
private final Whirlpool whirlpool;
private final String poolId;
public RegisteredInputsService(Whirlpool whirlpool, String poolId) {
this.whirlpool = whirlpool;
this.poolId = poolId;
}
@Override
protected Task<Integer> createTask() {
return new Task<>() {
protected Integer call() {
Pool pool = whirlpool.getPool(poolId);
if(pool != null) {
return pool.getNbRegistered();
}
return null;
}
};
}

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