mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 03:56:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f75b4582c8 | ||
|
|
5d823571df | ||
|
|
cca61d281c | ||
|
|
34c9bc9b69 | ||
|
|
5fa048d242 | ||
|
|
ca928fc136 | ||
|
|
9bf53ab0cd | ||
|
|
542cc7de6f | ||
|
|
77fde3cda9 | ||
|
|
1eb595823b | ||
|
|
6d2167428f | ||
|
|
3820b9838d | ||
|
|
20a99e3236 | ||
|
|
c81f3d9f5d | ||
|
|
526de33bdd | ||
|
|
ee992b7255 | ||
|
|
7d459a9115 | ||
|
|
9faf036e4d | ||
|
|
306f241a4a | ||
|
|
41d1a1806d | ||
|
|
a825a693c1 | ||
|
|
7423d94935 | ||
|
|
82f9a0f2af | ||
|
|
7da62bb135 | ||
|
|
f2e5259916 | ||
|
|
4f4a48eb98 | ||
|
|
9c3b647f07 | ||
|
|
7f2d72ee59 | ||
|
|
548b8d270b | ||
|
|
696746f06c | ||
|
|
6f11a20feb | ||
|
|
c28c2ed506 | ||
|
|
8fb7f544de | ||
|
|
a76d9dba21 | ||
|
|
f1b3f7d5dd | ||
|
|
fd9e19d052 | ||
|
|
56363c083e | ||
|
|
bbdfec127a | ||
|
|
eb1087bf8d | ||
|
|
796f68640c | ||
|
|
ad091217d6 | ||
|
|
40e06b96a9 | ||
|
|
3fd186e22c | ||
|
|
e8c7f57704 | ||
|
|
e6de33034b | ||
|
|
73b6b9219b | ||
|
|
d1d1b0806f | ||
|
|
5ee97966ee | ||
|
|
4819f329ae | ||
|
|
52aed8a3f5 | ||
|
|
86ef129c1b | ||
|
|
aff872eea0 | ||
|
|
5d0025b4a7 | ||
|
|
e625a4e542 | ||
|
|
72ff1df61e | ||
|
|
3776fbafd9 | ||
|
|
cd91aff3bb | ||
|
|
b530ced9ed | ||
|
|
22957e9d88 | ||
|
|
d76aecb34d | ||
|
|
4cbb402931 | ||
|
|
4da82b110c | ||
|
|
880096a193 | ||
|
|
06c0fb8594 | ||
|
|
8e85543c6e | ||
|
|
4fb72fdf89 | ||
|
|
cf2616ec2b | ||
|
|
238aae5ea9 | ||
|
|
a963d10381 | ||
|
|
eb90d6a31a | ||
|
|
b013b5f50f | ||
|
|
14db333a6f | ||
|
|
6804f713b2 | ||
|
|
90a2c3b89b | ||
|
|
3c94664ac3 | ||
|
|
1b61a78e6d | ||
|
|
1defe51fd7 | ||
|
|
4cbd778ca1 | ||
|
|
e59ee47624 | ||
|
|
8b42399423 | ||
|
|
e84f82f47b | ||
|
|
e4189711bd | ||
|
|
9bca911b0b | ||
|
|
eb498f2bcc | ||
|
|
3d13f596a0 | ||
|
|
28984427e1 | ||
|
|
ed92acc468 | ||
|
|
3b9c87abc7 | ||
|
|
00181875c1 | ||
|
|
26fb2b97fb | ||
|
|
44194a074c | ||
|
|
4edd84f6e2 | ||
|
|
0956c96046 | ||
|
|
3013688447 | ||
|
|
0302913c3f | ||
|
|
72768362a5 | ||
|
|
39fa65ea37 | ||
|
|
4554c9d0df | ||
|
|
9b9b295045 | ||
|
|
ece786131e | ||
|
|
dc65313313 | ||
|
|
cb41a1ed66 | ||
|
|
c566dea232 | ||
|
|
8f04d23b3f | ||
|
|
b8b1039ada | ||
|
|
a7aafa27d0 | ||
|
|
18a1e82dda | ||
|
|
e26587e807 | ||
|
|
c3778b6419 | ||
|
|
fb85277894 | ||
|
|
8ae3399d56 | ||
|
|
7272de90f0 | ||
|
|
98c1f05ed6 | ||
|
|
90439501ad | ||
|
|
1bb3833cbe | ||
|
|
d7d8140050 | ||
|
|
3c77552211 | ||
|
|
5e7d1d1f69 | ||
|
|
8f92f9ec38 | ||
|
|
af23f063f2 | ||
|
|
13a3ce23e7 | ||
|
|
d7ff73243c | ||
|
|
25dd0440f6 | ||
|
|
b5301c4556 | ||
|
|
a22f69e2c1 |
+40
-16
@@ -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.2'
|
||||
def sparrowVersion = '1.5.6'
|
||||
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.206')
|
||||
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.7-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.19')
|
||||
implementation('com.sparrowwallet.nightjar:nightjar:0.2.27')
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -122,7 +127,8 @@ test {
|
||||
}
|
||||
|
||||
run {
|
||||
applicationDefaultJvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
|
||||
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
|
||||
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
|
||||
@@ -191,7 +197,8 @@ 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 += ["-Dprism.lcdtext=false", "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"]
|
||||
@@ -204,14 +211,14 @@ jlink {
|
||||
appVersion = "${sparrowVersion}"
|
||||
skipInstaller = os.macOsX || properties.skipInstallers
|
||||
imageOptions = []
|
||||
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/aopp.properties', '--license-file', 'LICENSE']
|
||||
installerOptions = ['--file-associations', 'src/main/deploy/psbt.properties', '--file-associations', 'src/main/deploy/txn.properties', '--file-associations', 'src/main/deploy/bitcoin.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) {
|
||||
@@ -354,7 +361,7 @@ extraJavaModuleInfo {
|
||||
module('json-simple-1.1.1.jar', 'json.simple', '1.1.1') {
|
||||
exports('org.json.simple')
|
||||
}
|
||||
module('logback-classic-1.2.3.jar', 'logback.classic', '1.2.3') {
|
||||
module('logback-classic-1.2.8.jar', 'logback.classic', '1.2.8') {
|
||||
exports('ch.qos.logback.classic')
|
||||
requires('org.slf4j')
|
||||
requires('logback.core')
|
||||
@@ -431,6 +438,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 +453,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 +461,7 @@ extraJavaModuleInfo {
|
||||
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
|
||||
exports('co.nstant.in.cbor')
|
||||
}
|
||||
module('nightjar-0.2.19.jar', 'com.sparrowwallet.nightjar', '0.2.19') {
|
||||
module('nightjar-0.2.27.jar', 'com.sparrowwallet.nightjar', '0.2.27') {
|
||||
requires('com.google.common')
|
||||
requires('net.sourceforge.streamsupport')
|
||||
requires('org.slf4j')
|
||||
@@ -459,6 +471,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 +479,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')
|
||||
@@ -549,7 +573,7 @@ extraJavaModuleInfo {
|
||||
requires('org.slf4j')
|
||||
}
|
||||
module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0')
|
||||
module('logback-core-1.2.3.jar', 'logback.core', '1.2.3') {
|
||||
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
|
||||
requires('java.xml')
|
||||
}
|
||||
module('kotlin-stdlib-common-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.common', '1.5.20') {
|
||||
|
||||
+1
-1
@@ -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
Submodule drongo updated: 6c03c4106a...ee732fb223
@@ -1,2 +0,0 @@
|
||||
mime-type=x-scheme-handler/aopp
|
||||
description=Verify Address Ownership URI
|
||||
@@ -6,4 +6,4 @@ Icon=/opt/sparrow/lib/Sparrow.png
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Unknown
|
||||
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/aopp
|
||||
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.5.2</string>
|
||||
<string>1.5.6</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
@@ -45,14 +45,6 @@
|
||||
<string>bitcoin</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.sparrowwallet.sparrow.aopp</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>aopp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
|
||||
@@ -77,16 +77,6 @@
|
||||
|
||||
<DirectoryRef Id="TARGETDIR">
|
||||
<Component Id="RegistryEntries" Guid="{206C911C-56EF-44B8-9257-5FD214427965}">
|
||||
<RegistryKey Root="HKCR" Key="aopp" Action="createAndRemoveOnUninstall">
|
||||
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||
<RegistryValue Type="string" Value="URL:Address Ownership Proof Protocol"/>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[INSTALLDIR]$(var.JpAppName).exe" "%1"" />
|
||||
</RegistryKey>
|
||||
</RegistryKey>
|
||||
<RegistryKey Root="HKCR" Key="bitcoin" Action="createAndRemoveOnUninstall">
|
||||
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||
<RegistryValue Type="string" Value="URL:Bitcoin Payment URL"/>
|
||||
|
||||
@@ -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,9 +27,14 @@ 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.PayNymDialog;
|
||||
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;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletController;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
|
||||
@@ -55,10 +62,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;
|
||||
@@ -90,12 +94,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;
|
||||
|
||||
@@ -136,10 +146,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();
|
||||
@@ -150,12 +156,24 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private MenuItem lockWallet;
|
||||
|
||||
@FXML
|
||||
private MenuItem searchWallet;
|
||||
|
||||
@FXML
|
||||
private MenuItem refreshWallet;
|
||||
|
||||
@FXML
|
||||
private MenuItem sendToMany;
|
||||
|
||||
@FXML
|
||||
private MenuItem sweepPrivateKey;
|
||||
|
||||
@FXML
|
||||
private MenuItem findMixingPartner;
|
||||
|
||||
@FXML
|
||||
private MenuItem showPayNym;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem preventSleep;
|
||||
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
|
||||
@@ -176,6 +194,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<>();
|
||||
@@ -220,7 +240,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();
|
||||
@@ -239,6 +262,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) {
|
||||
@@ -300,18 +327,26 @@ 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);
|
||||
searchWallet.disableProperty().bind(exportWallet.disableProperty());
|
||||
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
|
||||
sendToMany.disableProperty().bind(exportWallet.disableProperty());
|
||||
sweepPrivateKey.disableProperty().bind(Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()));
|
||||
showPayNym.disableProperty().bind(findMixingPartner.disableProperty());
|
||||
findMixingPartner.setDisable(true);
|
||||
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
|
||||
findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
|
||||
});
|
||||
|
||||
setServerType(Config.get().getServerType());
|
||||
serverToggle.setSelected(isConnected());
|
||||
@@ -387,9 +422,10 @@ public class AppController implements Initializable {
|
||||
Parent root = loader.load();
|
||||
AboutController controller = loader.getController();
|
||||
|
||||
Stage stage = new Stage();
|
||||
Stage stage = new Stage(StageStyle.UNDECORATED);
|
||||
stage.setTitle("About " + MainApp.APP_NAME);
|
||||
stage.initStyle(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? StageStyle.UNDECORATED : StageStyle.DECORATED);
|
||||
stage.initOwner(tabs.getScene().getWindow());
|
||||
stage.initModality(Modality.WINDOW_MODAL);
|
||||
stage.setResizable(false);
|
||||
Scene scene = new Scene(root);
|
||||
AppServices.onEscapePressed(scene, stage::close);
|
||||
@@ -588,6 +624,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);
|
||||
}
|
||||
@@ -671,6 +724,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<>();
|
||||
|
||||
@@ -746,12 +811,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());
|
||||
@@ -827,8 +886,21 @@ public class AppController implements Initializable {
|
||||
try {
|
||||
Storage storage = new Storage(file);
|
||||
if(!storage.isEncrypted()) {
|
||||
WalletBackupAndKey walletBackupAndKey = storage.loadUnencryptedWallet();
|
||||
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
|
||||
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage);
|
||||
loadWalletService.setOnSucceeded(workerStateEvent -> {
|
||||
WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
|
||||
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
|
||||
});
|
||||
loadWalletService.setOnFailed(workerStateEvent -> {
|
||||
Throwable exception = workerStateEvent.getSource().getException();
|
||||
if(exception instanceof StorageException) {
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage());
|
||||
} else if(!attemptImportWallet(file, null)) {
|
||||
log.error("Error opening wallet", exception);
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage() == null || exception.getMessage().contains("Expected BEGIN_OBJECT") ? "Unsupported wallet file format." : exception.getMessage());
|
||||
}
|
||||
});
|
||||
loadWalletService.start();
|
||||
} else {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(storage.getWalletName(null), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
@@ -852,9 +924,11 @@ public class AppController implements Initializable {
|
||||
Platform.runLater(() -> openWalletFile(file, forceSameWindow));
|
||||
}
|
||||
} else {
|
||||
if(!attemptImportWallet(file, password)) {
|
||||
if(exception instanceof StorageException) {
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage());
|
||||
} else if(!attemptImportWallet(file, password)) {
|
||||
log.error("Error Opening Wallet", exception);
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage() == null ? "Unsupported file format" : exception.getMessage());
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage() == null || exception.getMessage().contains("Expected BEGIN_OBJECT") ? "Unsupported wallet file format." : exception.getMessage());
|
||||
}
|
||||
password.clear();
|
||||
}
|
||||
@@ -862,8 +936,6 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.START, "Decrypting wallet..."));
|
||||
loadWalletService.start();
|
||||
}
|
||||
} catch(StorageException e) {
|
||||
showErrorDialog("Error Opening Wallet", e.getMessage());
|
||||
} catch(Exception e) {
|
||||
if(e instanceof IOException && e.getMessage().startsWith("The process cannot access the file because another process has locked")) {
|
||||
log.error("Error opening wallet", e);
|
||||
@@ -939,6 +1011,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();
|
||||
@@ -1175,6 +1253,90 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
public void sweepPrivateKey(ActionEvent event) {
|
||||
Wallet wallet = null;
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
wallet = selectedWalletForm.getWallet();
|
||||
}
|
||||
|
||||
PrivateKeySweepDialog dialog = new PrivateKeySweepDialog(wallet);
|
||||
Optional<Transaction> optTransaction = dialog.showAndWait();
|
||||
optTransaction.ifPresent(transaction -> addTransactionTab(null, null, transaction));
|
||||
}
|
||||
|
||||
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 showPayNym(ActionEvent event) {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId(), false);
|
||||
payNymDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
public void minimizeToTray(ActionEvent event) {
|
||||
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
|
||||
}
|
||||
@@ -1186,6 +1348,37 @@ 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 searchWallet(ActionEvent event) {
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
if(selectedTab != null) {
|
||||
TabData tabData = (TabData) selectedTab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane) selectedTab.getContent();
|
||||
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
|
||||
if(!walletForms.isEmpty()) {
|
||||
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
|
||||
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
|
||||
if(optEntry.isPresent()) {
|
||||
Entry entry = optEntry.get();
|
||||
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
|
||||
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshWallet(ActionEvent event) {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
@@ -1334,7 +1527,7 @@ public class AppController implements Initializable {
|
||||
TabData tabData = new WalletTabData(TabData.TabType.WALLET, walletForm);
|
||||
subTab.setUserData(tabData);
|
||||
if(!wallet.isWhirlpoolChildWallet()) {
|
||||
subTab.setContextMenu(getSubTabContextMenu(subTab));
|
||||
subTab.setContextMenu(getSubTabContextMenu(wallet, subTabs, subTab));
|
||||
}
|
||||
|
||||
subTabs.getTabs().add(subTab);
|
||||
@@ -1370,12 +1563,14 @@ public class AppController implements Initializable {
|
||||
|
||||
public WalletForm getSelectedWalletForm() {
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
TabData tabData = (TabData)selectedTab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane)selectedTab.getContent();
|
||||
Tab selectedSubTab = subTabs.getSelectionModel().getSelectedItem();
|
||||
WalletTabData subWalletTabData = (WalletTabData)selectedSubTab.getUserData();
|
||||
return subWalletTabData.getWalletForm();
|
||||
if(selectedTab != null) {
|
||||
TabData tabData = (TabData)selectedTab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane)selectedTab.getContent();
|
||||
Tab selectedSubTab = subTabs.getSelectionModel().getSelectedItem();
|
||||
WalletTabData subWalletTabData = (WalletTabData)selectedSubTab.getUserData();
|
||||
return subWalletTabData.getWalletForm();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -1562,7 +1757,7 @@ public class AppController implements Initializable {
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
private ContextMenu getSubTabContextMenu(Tab subTab) {
|
||||
private ContextMenu getSubTabContextMenu(Wallet wallet, TabPane subTabs, Tab subTab) {
|
||||
ContextMenu contextMenu = new ContextMenu();
|
||||
MenuItem rename = new MenuItem("Rename Account");
|
||||
rename.setOnAction(event -> {
|
||||
@@ -1573,14 +1768,27 @@ public class AppController implements Initializable {
|
||||
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);
|
||||
|
||||
if(!wallet.isMasterWallet() && !wallet.isWhirlpoolChildWallet()) {
|
||||
MenuItem delete = new MenuItem("Delete Account");
|
||||
delete.setOnAction(event -> {
|
||||
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete Wallet Account?", "Labels applied in this wallet account will be lost. Are you sure?", ButtonType.CANCEL, ButtonType.OK);
|
||||
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.OK) {
|
||||
subTabs.getTabs().remove(subTab);
|
||||
if(subTabs.getTabs().size() == 1) {
|
||||
setSubTabsVisible(subTabs, false);
|
||||
}
|
||||
EventManager.get().post(new WalletDeletedEvent(wallet));
|
||||
}
|
||||
});
|
||||
contextMenu.getItems().add(delete);
|
||||
}
|
||||
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
@@ -1612,21 +1820,17 @@ public class AppController implements Initializable {
|
||||
private void serverToggleStartAnimation() {
|
||||
Node thumbArea = serverToggle.lookup(".thumb-area");
|
||||
if(thumbArea != null) {
|
||||
FadeTransition fadeTransition = new FadeTransition(Duration.millis(600), 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);
|
||||
}
|
||||
}
|
||||
@@ -1642,13 +1846,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1678,8 +1878,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);
|
||||
}
|
||||
@@ -1751,18 +1951,18 @@ 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();
|
||||
saveTransaction.setVisible(true);
|
||||
saveTransaction.setDisable(true);
|
||||
lockWallet.setDisable(walletTabData.getWalletForm().lockedProperty().get());
|
||||
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid());
|
||||
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid() || walletTabData.getWalletForm().isLocked());
|
||||
showLoadingLog.setDisable(false);
|
||||
showUtxosChart.setDisable(false);
|
||||
showTxHex.setDisable(true);
|
||||
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1786,7 +1986,8 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
|
||||
exportWallet.setDisable(!event.getWallet().isValid());
|
||||
exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked());
|
||||
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1809,7 +2010,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()) {
|
||||
@@ -1916,8 +2117,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);
|
||||
@@ -2248,12 +2456,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void sendAction(SendActionEvent event) {
|
||||
selectTab(event.getWallet());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void recieveAction(ReceiveActionEvent event) {
|
||||
public void functionAction(FunctionActionEvent event) {
|
||||
selectTab(event.getWallet());
|
||||
}
|
||||
|
||||
@@ -2272,6 +2475,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
|
||||
lockWallet.setDisable(true);
|
||||
exportWallet.setDisable(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2280,6 +2484,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
|
||||
lockWallet.setDisable(false);
|
||||
exportWallet.setDisable(!event.getWallet().isValid());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTransaction;
|
||||
import com.sparrowwallet.sparrow.control.TextUtils;
|
||||
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;
|
||||
@@ -73,10 +76,16 @@ public class AppServices {
|
||||
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
|
||||
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
|
||||
|
||||
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
|
||||
public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);
|
||||
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
|
||||
|
||||
private static AppServices INSTANCE;
|
||||
|
||||
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<>();
|
||||
@@ -103,7 +112,7 @@ public class AppServices {
|
||||
|
||||
private static Map<Integer, Double> targetBlockFeeRates;
|
||||
|
||||
private static final Map<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
|
||||
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
|
||||
|
||||
private static Double minimumRelayFeeRate;
|
||||
|
||||
@@ -151,6 +160,7 @@ public class AppServices {
|
||||
this.application = application;
|
||||
EventManager.get().register(this);
|
||||
EventManager.get().register(whirlpoolServices);
|
||||
EventManager.get().register(sorobanServices);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -471,6 +481,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 +496,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")));
|
||||
|
||||
@@ -535,6 +549,18 @@ public class AppServices {
|
||||
return getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
public WalletTransaction getCreatedTransaction(Set<BlockTransactionHashIndex> utxos) {
|
||||
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
|
||||
for(WalletTabData walletTabData : walletTabDataList) {
|
||||
if(walletTabData.getWalletForm().getCreatedWalletTransaction() != null && utxos.equals(walletTabData.getWalletForm().getCreatedWalletTransaction().getSelectedUtxos().keySet())) {
|
||||
return walletTabData.getWalletForm().getCreatedWalletTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public Window getWindowForWallet(String walletId) {
|
||||
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWalletForm().getWalletId().equals(walletId))).map(Map.Entry::getKey).findFirst();
|
||||
return optWindow.orElse(null);
|
||||
@@ -569,11 +595,16 @@ public class AppServices {
|
||||
return latestBlockHeader;
|
||||
}
|
||||
|
||||
public static Double getDefaultFeeRate() {
|
||||
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
|
||||
return getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getTargetBlockFeeRates().get(defaultTarget);
|
||||
}
|
||||
|
||||
public static Map<Integer, Double> getTargetBlockFeeRates() {
|
||||
return targetBlockFeeRates;
|
||||
}
|
||||
|
||||
public static Map<Date, Set<MempoolRateSize>> getMempoolHistogram() {
|
||||
public static TreeMap<Date, Set<MempoolRateSize>> getMempoolHistogram() {
|
||||
return mempoolHistogram;
|
||||
}
|
||||
|
||||
@@ -584,6 +615,9 @@ public class AppServices {
|
||||
}
|
||||
|
||||
mempoolHistogram.put(Date.from(dateMinute.atZone(ZoneId.systemDefault()).toInstant()), rateSizes);
|
||||
|
||||
Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
|
||||
mempoolHistogram.keySet().removeIf(date -> date.before(yesterday));
|
||||
}
|
||||
|
||||
public static Double getMinimumRelayFeeRate() {
|
||||
@@ -760,8 +794,6 @@ public class AppServices {
|
||||
Platform.runLater(() -> {
|
||||
if("bitcoin".equals(uri.getScheme())) {
|
||||
openBitcoinUri(uri);
|
||||
} else if("aopp".equals(uri.getScheme())) {
|
||||
openAddressOwnershipProof(uri);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -796,20 +828,6 @@ public class AppServices {
|
||||
}
|
||||
}
|
||||
|
||||
public static void openAddressOwnershipProof(URI uri) {
|
||||
try {
|
||||
Aopp aopp = new Aopp(uri);
|
||||
Wallet wallet = selectWallet(aopp.getScriptType(), "send proof of address");
|
||||
|
||||
if(wallet != null) {
|
||||
EventManager.get().post(new ReceiveActionEvent(wallet));
|
||||
Platform.runLater(() -> EventManager.get().post(new ReceiveProofEvent(wallet, aopp)));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
showErrorDialog("Not a valid AOPP URI", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Wallet selectWallet(ScriptType scriptType, String actionDescription) {
|
||||
Wallet wallet = null;
|
||||
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> scriptType == null || w.getScriptType() == scriptType).collect(Collectors.toList());
|
||||
@@ -846,6 +864,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.2";
|
||||
public static final String APP_VERSION = "1.5.6";
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class BalanceChart extends LineChart<Number, Number> {
|
||||
private static final int MAX_VALUES = 500;
|
||||
|
||||
private XYChart.Series<Number, Number> balanceSeries;
|
||||
|
||||
private TransactionEntry selectedEntry;
|
||||
@@ -37,7 +43,7 @@ public class BalanceChart extends LineChart<Number, Number> {
|
||||
setVisible(!walletTransactionsEntry.getChildren().isEmpty());
|
||||
balanceSeries.getData().clear();
|
||||
|
||||
List<Data<Number, Number>> balanceDataList = walletTransactionsEntry.getChildren().stream()
|
||||
List<Data<Number, Number>> balanceDataList = getTransactionEntries(walletTransactionsEntry)
|
||||
.map(entry -> (TransactionEntry)entry)
|
||||
.filter(txEntry -> txEntry.getBlockTransaction().getHeight() > 0)
|
||||
.map(txEntry -> new XYChart.Data<>((Number)txEntry.getBlockTransaction().getDate().getTime(), (Number)txEntry.getBalance(), txEntry))
|
||||
@@ -74,6 +80,24 @@ public class BalanceChart extends LineChart<Number, Number> {
|
||||
}
|
||||
}
|
||||
|
||||
private Stream<Entry> getTransactionEntries(WalletTransactionsEntry walletTransactionsEntry) {
|
||||
int total = walletTransactionsEntry.getChildren().size();
|
||||
if(walletTransactionsEntry.getChildren().size() <= MAX_VALUES) {
|
||||
return walletTransactionsEntry.getChildren().stream();
|
||||
}
|
||||
|
||||
int bucketSize = total / MAX_VALUES;
|
||||
List<List<Entry>> buckets = Lists.partition(walletTransactionsEntry.getChildren(), bucketSize);
|
||||
List<Entry> reducedEntries = new ArrayList<>(MAX_VALUES);
|
||||
for(List<Entry> bucket : buckets) {
|
||||
long max = bucket.stream().mapToLong(entry -> Math.abs(entry.getValue())).max().orElse(0);
|
||||
Entry bucketEntry = bucket.stream().filter(entry -> entry.getValue() == max || entry.getValue() == -max).findFirst().orElseThrow();
|
||||
reducedEntries.add(bucketEntry);
|
||||
}
|
||||
|
||||
return reducedEntries.stream();
|
||||
}
|
||||
|
||||
public void select(TransactionEntry transactionEntry) {
|
||||
Set<Node> selectedSymbols = lookupAll(".chart-line-symbol.selected");
|
||||
for(Node selectedSymbol : selectedSymbols) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.wallet.SendController;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.chart.Axis;
|
||||
@@ -28,7 +28,7 @@ public class BlockTargetFeeRatesChart extends LineChart<String, Number> {
|
||||
|
||||
for(Iterator<Integer> targetBlocksIter = targetBlocksFeeRates.keySet().iterator(); targetBlocksIter.hasNext(); ) {
|
||||
Integer targetBlocks = targetBlocksIter.next();
|
||||
if(SendController.TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
|
||||
if(AppServices.TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
|
||||
String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+");
|
||||
XYChart.Data<String, Number> data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks));
|
||||
feeRateSeries.getData().add(data);
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.Clipboard;
|
||||
@@ -15,7 +16,7 @@ import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.Locale;
|
||||
|
||||
public class CoinLabel extends CopyableLabel {
|
||||
public class CoinLabel extends Label {
|
||||
public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
|
||||
private final LongProperty valueProperty = new SimpleLongProperty(-1);
|
||||
@@ -28,7 +29,6 @@ public class CoinLabel extends CopyableLabel {
|
||||
|
||||
public CoinLabel(String text) {
|
||||
super(text);
|
||||
BTC_FORMAT.setMaximumFractionDigits(8);
|
||||
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getBitcoinUnit()));
|
||||
tooltip = new Tooltip();
|
||||
contextMenu = new CoinContextMenu();
|
||||
@@ -77,7 +77,7 @@ public class CoinLabel extends CopyableLabel {
|
||||
|
||||
private class CoinContextMenu extends ContextMenu {
|
||||
public CoinContextMenu() {
|
||||
MenuItem copySatsValue = new MenuItem("Copy Value in Satoshis");
|
||||
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
|
||||
copySatsValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.Cursor;
|
||||
@@ -12,13 +13,22 @@ 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");
|
||||
setupCopyButtonField(super.rightProperty());
|
||||
setupComboButtonField(super.rightProperty());
|
||||
|
||||
disabledProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(comboProperty.isNotNull().get()) {
|
||||
comboProperty.get().setVisible(!newValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
|
||||
private void setupComboButtonField(ObjectProperty<Node> rightProperty) {
|
||||
Region showComboButton = new Region();
|
||||
showComboButton.getStyleClass().addAll("graphic"); //$NON-NLS-1$
|
||||
StackPane showComboButtonPane = new StackPane(showComboButton);
|
||||
@@ -26,7 +36,21 @@ public class ComboBoxTextField extends CustomTextField {
|
||||
showComboButtonPane.setCursor(Cursor.DEFAULT);
|
||||
showComboButtonPane.setOnMouseReleased(e -> {
|
||||
if(comboProperty.isNotNull().get()) {
|
||||
comboProperty.get().show();
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class CopyableCoinLabel extends CopyableLabel {
|
||||
private final LongProperty valueProperty = new SimpleLongProperty(-1);
|
||||
private final Tooltip tooltip;
|
||||
private final CoinContextMenu contextMenu;
|
||||
|
||||
public CopyableCoinLabel() {
|
||||
this("Unknown");
|
||||
}
|
||||
|
||||
public CopyableCoinLabel(String text) {
|
||||
super(text);
|
||||
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getBitcoinUnit()));
|
||||
tooltip = new Tooltip();
|
||||
contextMenu = new CoinContextMenu();
|
||||
}
|
||||
|
||||
public final LongProperty valueProperty() {
|
||||
return valueProperty;
|
||||
}
|
||||
|
||||
public final long getValue() {
|
||||
return valueProperty.get();
|
||||
}
|
||||
|
||||
public final void setValue(long value) {
|
||||
this.valueProperty.set(value);
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
refresh(Config.get().getBitcoinUnit());
|
||||
}
|
||||
|
||||
public void refresh(BitcoinUnit bitcoinUnit) {
|
||||
setValueAsText(getValue(), bitcoinUnit);
|
||||
}
|
||||
|
||||
private void setValueAsText(Long value, BitcoinUnit bitcoinUnit) {
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
String satsValue = String.format(Locale.ENGLISH, "%,d", value) + " sats";
|
||||
String btcValue = CoinLabel.getBTCFormat().format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC";
|
||||
|
||||
BitcoinUnit unit = bitcoinUnit;
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = (value >= BitcoinUnit.getAutoThreshold() ? BitcoinUnit.BTC : BitcoinUnit.SATOSHIS);
|
||||
}
|
||||
|
||||
if(unit.equals(BitcoinUnit.BTC)) {
|
||||
tooltip.setText(satsValue);
|
||||
setText(btcValue);
|
||||
} else {
|
||||
tooltip.setText(btcValue);
|
||||
setText(satsValue);
|
||||
}
|
||||
}
|
||||
|
||||
private class CoinContextMenu extends ContextMenu {
|
||||
public CoinContextMenu() {
|
||||
MenuItem copySatsValue = new MenuItem("Copy Value in sats");
|
||||
copySatsValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(Long.toString(getValue()));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyBtcValue = new MenuItem("Copy Value in BTC");
|
||||
copyBtcValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(CoinLabel.getBTCFormat().format((double)getValue() / Transaction.SATOSHIS_PER_BITCOIN));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copySatsValue, copyBtcValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -332,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);
|
||||
@@ -372,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 -> {
|
||||
@@ -625,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...");
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Pos;
|
||||
@@ -69,10 +70,17 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
}
|
||||
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setText(transactionEntry.getBlockTransaction().getHash().toString());
|
||||
tooltip.setText(getTooltip(transactionEntry));
|
||||
setTooltip(tooltip);
|
||||
|
||||
if(transactionEntry.getBlockTransaction().getHeight() <= 0) {
|
||||
tooltip.setOnShowing(event -> {
|
||||
tooltip.setText(getTooltip(transactionEntry));
|
||||
});
|
||||
}
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
actionBox.getStyleClass().add("cell-actions");
|
||||
Button viewTransactionButton = new Button("");
|
||||
viewTransactionButton.setGraphic(getViewTransactionGlyph());
|
||||
viewTransactionButton.setOnAction(event -> {
|
||||
@@ -111,6 +119,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
getStyleClass().add("address-cell");
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
actionBox.getStyleClass().add("cell-actions");
|
||||
Button receiveButton = new Button("");
|
||||
receiveButton.setGraphic(getReceiveGlyph());
|
||||
receiveButton.setOnAction(event -> {
|
||||
@@ -145,6 +154,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
setTooltip(tooltip);
|
||||
|
||||
HBox actionBox = new HBox();
|
||||
actionBox.getStyleClass().add("cell-actions");
|
||||
Button viewTransactionButton = new Button("");
|
||||
viewTransactionButton.setGraphic(getViewTransactionGlyph());
|
||||
viewTransactionButton.setOnAction(event -> {
|
||||
@@ -326,6 +336,30 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), utxos));
|
||||
}
|
||||
|
||||
private String getTooltip(TransactionEntry transactionEntry) {
|
||||
String tooltip = transactionEntry.getBlockTransaction().getHash().toString();
|
||||
if(transactionEntry.getBlockTransaction().getHeight() <= 0) {
|
||||
if(!AppServices.getMempoolHistogram().isEmpty()) {
|
||||
Set<MempoolRateSize> rateSizes = AppServices.getMempoolHistogram().get(AppServices.getMempoolHistogram().lastKey());
|
||||
double vSize = transactionEntry.getBlockTransaction().getTransaction().getVirtualSize();
|
||||
double feeRate = transactionEntry.getBlockTransaction().getFee() / vSize;
|
||||
long vSizefromTip = rateSizes.stream().filter(rateSize -> rateSize.getFee() > feeRate).mapToLong(MempoolRateSize::getVSize).sum();
|
||||
String amount = vSizefromTip + " vB";
|
||||
if(vSizefromTip > 1000 * 1000) {
|
||||
amount = String.format("%.2f", (double)vSizefromTip / (1000 * 1000)) + " MvB";
|
||||
} else if(vSizefromTip > 1000) {
|
||||
amount = String.format("%.2f", (double)vSizefromTip / 1000) + " kvB";
|
||||
}
|
||||
|
||||
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB (" + amount + " from tip)";
|
||||
}
|
||||
|
||||
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled");
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private static Glyph getViewTransactionGlyph() {
|
||||
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
|
||||
searchGlyph.setFontSize(12);
|
||||
|
||||
@@ -42,12 +42,14 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
protected ButtonBase importButton;
|
||||
private final SimpleStringProperty password = new SimpleStringProperty("");
|
||||
private final boolean scannable;
|
||||
private final boolean fileFormatAvailable;
|
||||
protected List<Wallet> wallets;
|
||||
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) {
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable, boolean fileFormatAvailable) {
|
||||
super(title, description, content, imageUrl);
|
||||
this.importer = importer;
|
||||
this.scannable = scannable;
|
||||
this.fileFormatAvailable = fileFormatAvailable;
|
||||
|
||||
buttonBox.getChildren().clear();
|
||||
buttonBox.getChildren().add(createButton());
|
||||
@@ -55,7 +57,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
if(scannable) {
|
||||
if(scannable && fileFormatAvailable) {
|
||||
ToggleButton scanButton = new ToggleButton("Scan...");
|
||||
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
|
||||
cameraGlyph.setFontSize(12);
|
||||
@@ -76,6 +78,16 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
SegmentedButton segmentedButton = new SegmentedButton();
|
||||
segmentedButton.getButtons().addAll(scanButton, fileButton);
|
||||
return segmentedButton;
|
||||
} else if(scannable) {
|
||||
importButton = new Button("Scan...");
|
||||
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
|
||||
cameraGlyph.setFontSize(12);
|
||||
importButton.setGraphic(cameraGlyph);
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setOnAction(event -> {
|
||||
importQR();
|
||||
});
|
||||
return importButton;
|
||||
} else {
|
||||
importButton = new Button("Import File...");
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
@@ -146,6 +158,15 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
try {
|
||||
importFile(importer.getName(), null, null);
|
||||
} catch(ImportException e) {
|
||||
log.error("Error importing QR", e);
|
||||
setError("Import Error", e.getMessage());
|
||||
}
|
||||
} else if(result.outputDescriptor != null) {
|
||||
wallets = List.of(result.outputDescriptor.toKeystoreWallet(null));
|
||||
try {
|
||||
importFile(importer.getName(), null, null);
|
||||
} catch(ImportException e) {
|
||||
log.error("Error importing QR", e);
|
||||
setError("Import Error", e.getMessage());
|
||||
}
|
||||
} else if(result.payload != null) {
|
||||
@@ -165,6 +186,9 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error importing QR", result.exception);
|
||||
setError("Import Error", result.exception.getMessage());
|
||||
} else {
|
||||
setError("Import Error", null);
|
||||
setExpanded(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +214,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);
|
||||
|
||||
@@ -16,7 +16,7 @@ public class FileKeystoreImportPane extends FileImportPane {
|
||||
private final KeyDerivation requiredDerivation;
|
||||
|
||||
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer, KeyDerivation requiredDerivation) {
|
||||
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable());
|
||||
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
this.requiredDerivation = requiredDerivation;
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane {
|
||||
private final WalletImport importer;
|
||||
|
||||
public FileWalletImportPane(WalletImport importer) {
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable());
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), true);
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -39,7 +40,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
|
||||
private byte[] fileBytes;
|
||||
|
||||
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable());
|
||||
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
@@ -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,7 +25,7 @@ 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 ? " for " + walletName : ""));
|
||||
|
||||
@@ -92,18 +92,15 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
|
||||
});
|
||||
getItems().add(copyLabel);
|
||||
|
||||
Object content = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
|
||||
if(content instanceof String) {
|
||||
MenuItem pasteLabel = new MenuItem("Paste Label");
|
||||
pasteLabel.setOnAction(AE -> {
|
||||
hide();
|
||||
Object currentContent = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
|
||||
if(currentContent instanceof String) {
|
||||
entry.labelProperty().set((String)currentContent);
|
||||
}
|
||||
});
|
||||
getItems().add(pasteLabel);
|
||||
}
|
||||
MenuItem pasteLabel = new MenuItem("Paste Label");
|
||||
pasteLabel.setOnAction(AE -> {
|
||||
hide();
|
||||
Object currentContent = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
|
||||
if(currentContent instanceof String) {
|
||||
entry.labelProperty().set((String)currentContent);
|
||||
}
|
||||
});
|
||||
getItems().add(pasteLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||
import com.sparrowwallet.sparrow.wallet.SendController;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -79,9 +79,9 @@ public class MempoolSizeFeeRatesChart extends StackedAreaChart<String, Number> {
|
||||
});
|
||||
|
||||
long previousFeeRate = 0;
|
||||
for(Long feeRate : SendController.FEE_RATES_RANGE) {
|
||||
for(Long feeRate : AppServices.FEE_RATES_RANGE) {
|
||||
XYChart.Series<String, Number> series = new XYChart.Series<>();
|
||||
series.setName(feeRate + "+ vB");
|
||||
series.setName(feeRate + "+ sats/vB");
|
||||
long seriesTotalVSize = 0;
|
||||
|
||||
for(Date date : periodRateSizes.keySet()) {
|
||||
|
||||
@@ -103,6 +103,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title);
|
||||
|
||||
@@ -120,6 +121,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
Form form = new Form();
|
||||
Fieldset fieldset = new Fieldset();
|
||||
fieldset.setText("");
|
||||
fieldset.setSpacing(10);
|
||||
|
||||
Field addressField = new Field();
|
||||
addressField.setText("Address:");
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.FadeTransition;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.util.Duration;
|
||||
@@ -84,13 +84,13 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
|
||||
"\nTo prevent sleeping, use the " + getPlatformSleepConfig() + " or enable the function in the Tools menu.");
|
||||
setTooltip(tt);
|
||||
|
||||
FadeTransition ft = new FadeTransition(Duration.millis(ERROR_DISPLAY_MILLIS - elapsed), failGlyph);
|
||||
ft.setFromValue(1.0 - ((double)elapsed / ERROR_DISPLAY_MILLIS));
|
||||
ft.setToValue(0.0);
|
||||
ft.setOnFinished(event -> {
|
||||
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);
|
||||
});
|
||||
ft.play();
|
||||
timeline.play();
|
||||
} 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,343 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.io.Files;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.crypto.DumpedPrivateKey;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
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 tornadofx.control.Field;
|
||||
import tornadofx.control.Fieldset;
|
||||
import tornadofx.control.Form;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
|
||||
|
||||
public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
private static final Logger log = LoggerFactory.getLogger(PrivateKeySweepDialog.class);
|
||||
|
||||
private final TextArea key;
|
||||
private final ComboBox<ScriptType> keyScriptType;
|
||||
private final CopyableLabel keyAddress;
|
||||
private final ComboBoxTextField toAddress;
|
||||
private final ComboBox<Wallet> toWallet;
|
||||
|
||||
public PrivateKeySweepDialog(Wallet wallet) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText("Sweep Private Key");
|
||||
|
||||
Image image = new Image("image/seed.png", 50, 50, false, false);
|
||||
if(!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setSmooth(false);
|
||||
imageView.setImage(image);
|
||||
dialogPane.setGraphic(imageView);
|
||||
}
|
||||
|
||||
Form form = new Form();
|
||||
Fieldset fieldset = new Fieldset();
|
||||
fieldset.setText("");
|
||||
fieldset.setSpacing(10);
|
||||
|
||||
Field keyField = new Field();
|
||||
keyField.setText("Private Key:");
|
||||
key = new TextArea();
|
||||
key.setWrapText(true);
|
||||
key.setPromptText("Wallet Import Format (WIF)");
|
||||
key.setPrefRowCount(2);
|
||||
key.getStyleClass().add("fixed-width");
|
||||
HBox keyBox = new HBox(5);
|
||||
VBox keyButtonBox = new VBox(5);
|
||||
Button scanKey = new Button("", getGlyph(FontAwesome5.Glyph.CAMERA));
|
||||
scanKey.setOnAction(event -> scanPrivateKey());
|
||||
Button readKey = new Button("", getGlyph(FontAwesome5.Glyph.FILE_IMPORT));
|
||||
readKey.setOnAction(event -> readPrivateKey());
|
||||
keyButtonBox.getChildren().addAll(scanKey, readKey);
|
||||
keyBox.getChildren().addAll(key, keyButtonBox);
|
||||
HBox.setHgrow(key, Priority.ALWAYS);
|
||||
keyField.getInputs().add(keyBox);
|
||||
|
||||
Field keyScriptTypeField = new Field();
|
||||
keyScriptTypeField.setText("Script Type:");
|
||||
keyScriptType = new ComboBox<>();
|
||||
keyScriptType.setItems(FXCollections.observableList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE)));
|
||||
keyScriptTypeField.getInputs().add(keyScriptType);
|
||||
|
||||
keyScriptType.setConverter(new StringConverter<ScriptType>() {
|
||||
@Override
|
||||
public String toString(ScriptType scriptType) {
|
||||
return scriptType == null ? "" : scriptType.getDescription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScriptType fromString(String string) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
Field addressField = new Field();
|
||||
addressField.setText("Address:");
|
||||
keyAddress = new CopyableLabel();
|
||||
keyAddress.getStyleClass().add("fixed-width");
|
||||
addressField.getInputs().add(keyAddress);
|
||||
|
||||
Field toAddressField = new Field();
|
||||
toAddressField.setText("Sweep to:");
|
||||
toAddress = new ComboBoxTextField();
|
||||
toAddress.getStyleClass().add("fixed-width");
|
||||
toWallet = new ComboBox<>();
|
||||
toWallet.setItems(FXCollections.observableList(new ArrayList<>(AppServices.get().getOpenWallets().keySet())));
|
||||
toAddress.setComboProperty(toWallet);
|
||||
toWallet.prefWidthProperty().bind(toAddress.widthProperty());
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().addAll(toWallet, toAddress);
|
||||
toAddressField.getInputs().add(stackPane);
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
|
||||
ButtonType createButtonType = new javafx.scene.control.ButtonType("Create Transaction", ButtonBar.ButtonData.APPLY);
|
||||
ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType, createButtonType);
|
||||
|
||||
Button createButton = (Button) dialogPane.lookupButton(createButtonType);
|
||||
createButton.setDefaultButton(true);
|
||||
createButton.setDisable(true);
|
||||
createButton.addEventFilter(ActionEvent.ACTION, event -> {
|
||||
createTransaction();
|
||||
event.consume();
|
||||
});
|
||||
|
||||
key.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
boolean isValidKey = isValidKey();
|
||||
createButton.setDisable(!isValidKey || !isValidToAddress());
|
||||
if(isValidKey) {
|
||||
setFromAddress();
|
||||
}
|
||||
});
|
||||
|
||||
keyScriptType.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(isValidKey()) {
|
||||
setFromAddress();
|
||||
}
|
||||
});
|
||||
|
||||
toAddress.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
createButton.setDisable(!isValidKey() || !isValidToAddress());
|
||||
});
|
||||
|
||||
toWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> {
|
||||
if(selectedWallet != null) {
|
||||
toAddress.setText(selectedWallet.getAddress(selectedWallet.getFreshNode(KeyPurpose.RECEIVE)).toString());
|
||||
}
|
||||
});
|
||||
|
||||
keyScriptType.setValue(ScriptType.P2PKH);
|
||||
if(wallet != null) {
|
||||
toAddress.setText(wallet.getAddress(wallet.getFreshNode(KeyPurpose.RECEIVE)).toString());
|
||||
}
|
||||
|
||||
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(null));
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
setResultConverter(dialogButton -> null);
|
||||
dialogPane.setPrefWidth(680);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater(() -> {
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.registerValidator(key, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid private Key", !key.getText().isEmpty() && !isValidKey()));
|
||||
validationSupport.registerValidator(toAddress, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !toAddress.getText().isEmpty() && !isValidToAddress()));
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isValidKey() {
|
||||
try {
|
||||
DumpedPrivateKey privateKey = getPrivateKey();
|
||||
return true;
|
||||
} catch(Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DumpedPrivateKey getPrivateKey() {
|
||||
return DumpedPrivateKey.fromBase58(key.getText());
|
||||
}
|
||||
|
||||
private boolean isValidToAddress() {
|
||||
try {
|
||||
Address address = getToAddress();
|
||||
return true;
|
||||
} catch (InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Address getToAddress() throws InvalidAddressException {
|
||||
return Address.fromString(toAddress.getText());
|
||||
}
|
||||
|
||||
private void setFromAddress() {
|
||||
DumpedPrivateKey privateKey = getPrivateKey();
|
||||
ScriptType scriptType = keyScriptType.getValue();
|
||||
Address address = scriptType.getAddress(privateKey.getKey());
|
||||
keyAddress.setText(address.toString());
|
||||
}
|
||||
|
||||
private void scanPrivateKey() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
|
||||
if(result.isPresent() && result.get().payload != null) {
|
||||
key.setText(result.get().payload);
|
||||
}
|
||||
}
|
||||
|
||||
private void readPrivateKey() {
|
||||
Stage window = new Stage();
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open Private Key File");
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if(file != null) {
|
||||
if(file.length() > 1024) {
|
||||
AppServices.showErrorDialog("Invalid private key file", "This file does not contain a valid private key.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
key.setText(Files.asCharSource(file, StandardCharsets.UTF_8).read().trim());
|
||||
} catch(IOException e) {
|
||||
AppServices.showErrorDialog("Error reading private key file", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void createTransaction() {
|
||||
try {
|
||||
DumpedPrivateKey privateKey = getPrivateKey();
|
||||
ScriptType scriptType = keyScriptType.getValue();
|
||||
Address fromAddress = scriptType.getAddress(privateKey.getKey());
|
||||
Address destAddress = getToAddress();
|
||||
|
||||
ElectrumServer.AddressUtxosService addressUtxosService = new ElectrumServer.AddressUtxosService(fromAddress);
|
||||
addressUtxosService.setOnSucceeded(successEvent -> {
|
||||
createTransaction(privateKey.getKey(), scriptType, addressUtxosService.getValue(), destAddress);
|
||||
});
|
||||
addressUtxosService.setOnFailed(failedEvent -> {
|
||||
log.error("Error retrieving outputs for address " + fromAddress, failedEvent.getSource().getException());
|
||||
AppServices.showErrorDialog("Error retrieving outputs for address", failedEvent.getSource().getException().getMessage());
|
||||
});
|
||||
addressUtxosService.start();
|
||||
} catch(Exception e) {
|
||||
log.error("Error creating sweep transaction", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createTransaction(ECKey privKey, ScriptType scriptType, List<TransactionOutput> txOutputs, Address destAddress) {
|
||||
ECKey pubKey = ECKey.fromPublicOnly(privKey);
|
||||
|
||||
Transaction noFeeTransaction = new Transaction();
|
||||
long total = 0;
|
||||
for(TransactionOutput txOutput : txOutputs) {
|
||||
scriptType.addSpendingInput(noFeeTransaction, txOutput, pubKey, TransactionSignature.dummy(scriptType == P2TR ? TransactionSignature.Type.SCHNORR : TransactionSignature.Type.ECDSA));
|
||||
total += txOutput.getValue();
|
||||
}
|
||||
|
||||
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
|
||||
noFeeTransaction.addOutput(sweepOutput);
|
||||
|
||||
Double feeRate = AppServices.getDefaultFeeRate();
|
||||
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
|
||||
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||
fee++;
|
||||
}
|
||||
|
||||
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
|
||||
if(total - fee <= dustThreshold) {
|
||||
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
|
||||
return;
|
||||
}
|
||||
|
||||
Transaction transaction = new Transaction();
|
||||
transaction.setVersion(2);
|
||||
transaction.setLocktime(AppServices.getCurrentBlockHeight() == null ? 0 : AppServices.getCurrentBlockHeight());
|
||||
for(TransactionInput txInput : noFeeTransaction.getInputs()) {
|
||||
transaction.addInput(txInput);
|
||||
}
|
||||
transaction.addOutput(new TransactionOutput(transaction, total - fee, destAddress.getOutputScript()));
|
||||
|
||||
PSBT psbt = new PSBT(transaction);
|
||||
for(int i = 0; i < txOutputs.size(); i++) {
|
||||
TransactionOutput utxoOutput = txOutputs.get(i);
|
||||
TransactionInput txInput = transaction.getInputs().get(i);
|
||||
PSBTInput psbtInput = psbt.getPsbtInputs().get(i);
|
||||
psbtInput.setWitnessUtxo(utxoOutput);
|
||||
|
||||
if(ScriptType.P2SH.isScriptType(utxoOutput.getScript())) {
|
||||
psbtInput.setRedeemScript(txInput.getScriptSig().getFirstNestedScript());
|
||||
}
|
||||
|
||||
if(txInput.getWitness() != null) {
|
||||
psbtInput.setWitnessScript(txInput.getWitness().getWitnessScript());
|
||||
}
|
||||
|
||||
if(!psbtInput.sign(scriptType.getOutputKey(privKey))) {
|
||||
AppServices.showErrorDialog("Failed to sign", "Failed to sign for transaction output " + utxoOutput.getHash() + ":" + utxoOutput.getIndex());
|
||||
return;
|
||||
}
|
||||
|
||||
TransactionSignature signature = psbtInput.isTaproot() ? psbtInput.getTapKeyPathSignature() : psbtInput.getPartialSignature(pubKey);
|
||||
|
||||
Transaction finalizeTransaction = new Transaction();
|
||||
TransactionInput finalizedTxInput = scriptType.addSpendingInput(finalizeTransaction, utxoOutput, pubKey, signature);
|
||||
psbtInput.setFinalScriptSig(finalizedTxInput.getScriptSig());
|
||||
psbtInput.setFinalScriptWitness(finalizedTxInput.getWitness());
|
||||
}
|
||||
|
||||
setResult(psbt.extractTransaction());
|
||||
}
|
||||
|
||||
public Glyph getGlyph(FontAwesome5.Glyph glyphEnum) {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphEnum);
|
||||
glyph.setFontSize(12);
|
||||
return glyph;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import com.sparrowwallet.drongo.address.P2PKHAddress;
|
||||
import com.sparrowwallet.drongo.address.P2SHAddress;
|
||||
import com.sparrowwallet.drongo.address.P2WPKHAddress;
|
||||
import com.sparrowwallet.drongo.crypto.*;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.Base43;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
@@ -479,18 +481,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
List<Wallet> wallets = new ArrayList<>();
|
||||
String masterFingerprint = Utils.bytesToHex(cryptoAccount.getMasterFingerprint());
|
||||
for(CryptoOutput cryptoOutput : cryptoAccount.getOutputDescriptors()) {
|
||||
Wallet wallet = new Wallet();
|
||||
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
|
||||
if(outputDescriptor.isMultisig()) {
|
||||
throw new IllegalStateException("Multisig output descriptors are unsupported in CryptoAccount");
|
||||
}
|
||||
|
||||
ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey();
|
||||
wallet.setScriptType(outputDescriptor.getScriptType());
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(outputDescriptor.getKeyDerivation(extendedKey).getDerivation())));
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
wallet.getKeystores().add(keystore);
|
||||
Wallet wallet = outputDescriptor.toKeystoreWallet(masterFingerprint);
|
||||
wallets.add(wallet);
|
||||
}
|
||||
|
||||
@@ -499,7 +491,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 +509,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);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import tornadofx.control.Field;
|
||||
import tornadofx.control.Fieldset;
|
||||
import tornadofx.control.Form;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SearchWalletDialog extends Dialog<Entry> {
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
|
||||
|
||||
private final List<WalletForm> walletForms;
|
||||
private final TextField search;
|
||||
private final CoinTreeTable results;
|
||||
|
||||
public SearchWalletDialog(List<WalletForm> walletForms) {
|
||||
this.walletForms = walletForms;
|
||||
|
||||
if(walletForms.isEmpty()) {
|
||||
throw new IllegalArgumentException("No wallets selected to search");
|
||||
}
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText("Search Wallet");
|
||||
|
||||
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
|
||||
if(!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setSmooth(false);
|
||||
imageView.setImage(image);
|
||||
dialogPane.setGraphic(imageView);
|
||||
}
|
||||
|
||||
VBox vBox = new VBox();
|
||||
vBox.setSpacing(20);
|
||||
|
||||
Form form = new Form();
|
||||
Fieldset fieldset = new Fieldset();
|
||||
fieldset.setText("");
|
||||
fieldset.setSpacing(10);
|
||||
|
||||
Field searchField = new Field();
|
||||
searchField.setText("Search:");
|
||||
search = TextFields.createClearableTextField();
|
||||
search.setPromptText("Label, address, value or transaction ID");
|
||||
searchField.getInputs().add(search);
|
||||
|
||||
fieldset.getChildren().addAll(searchField);
|
||||
form.getChildren().add(fieldset);
|
||||
|
||||
results = new CoinTreeTable();
|
||||
results.setShowRoot(false);
|
||||
results.setPrefWidth(walletForms.size() > 1 ? 950 : 850);
|
||||
results.setBitcoinUnit(walletForms.iterator().next().getWallet());
|
||||
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
results.setPlaceholder(new Label("No results"));
|
||||
|
||||
if(walletForms.size() > 1) {
|
||||
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
|
||||
walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
|
||||
});
|
||||
results.getColumns().add(walletColumn);
|
||||
}
|
||||
|
||||
TreeTableColumn<Entry, String> typeColumn = new TreeTableColumn<>("Type");
|
||||
typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType());
|
||||
});
|
||||
results.getColumns().add(typeColumn);
|
||||
|
||||
TreeTableColumn<Entry, Entry> entryCol = new TreeTableColumn<>("Date / Address / Output");
|
||||
entryCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
|
||||
});
|
||||
entryCol.setCellFactory(p -> new SearchEntryCell());
|
||||
String address = walletForms.iterator().next().getNodeEntry(KeyPurpose.RECEIVE).getAddress().toString();
|
||||
entryCol.setMinWidth(TextUtils.computeTextWidth(AppServices.getMonospaceFont(), address, 0.0));
|
||||
results.getColumns().add(entryCol);
|
||||
|
||||
TreeTableColumn<Entry, String> labelCol = new TreeTableColumn<>("Label");
|
||||
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return param.getValue().getValue().labelProperty();
|
||||
});
|
||||
labelCol.setCellFactory(p -> new SearchLabelCell());
|
||||
results.getColumns().add(labelCol);
|
||||
|
||||
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
|
||||
amountCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
amountCol.setCellFactory(p -> new CoinCell());
|
||||
results.getColumns().add(amountCol);
|
||||
|
||||
vBox.getChildren().addAll(form, results);
|
||||
dialogPane.setContent(vBox);
|
||||
|
||||
ButtonType showButtonType = new javafx.scene.control.ButtonType("Show", ButtonBar.ButtonData.APPLY);
|
||||
ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType, showButtonType);
|
||||
|
||||
Button showButton = (Button) dialogPane.lookupButton(showButtonType);
|
||||
showButton.setDefaultButton(true);
|
||||
showButton.setDisable(true);
|
||||
|
||||
setResultConverter(buttonType -> buttonType == showButtonType ? results.getSelectionModel().getSelectedItem().getValue() : null);
|
||||
|
||||
results.getSelectionModel().getSelectedIndices().addListener((ListChangeListener<Integer>) c -> {
|
||||
showButton.setDisable(results.getSelectionModel().getSelectedCells().isEmpty());
|
||||
});
|
||||
|
||||
search.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
searchWallet(newValue.toLowerCase());
|
||||
});
|
||||
|
||||
setResizable(true);
|
||||
|
||||
Platform.runLater(search::requestFocus);
|
||||
}
|
||||
|
||||
private void searchWallet(String searchText) {
|
||||
List<Entry> matchingEntries = new ArrayList<>();
|
||||
|
||||
if(!searchText.isEmpty()) {
|
||||
Long searchValue = null;
|
||||
|
||||
try {
|
||||
searchValue = Math.abs(Long.parseLong(searchText));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
for(WalletForm walletForm : walletForms) {
|
||||
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
|
||||
for(Entry entry : walletTransactionsEntry.getChildren()) {
|
||||
if(entry instanceof TransactionEntry transactionEntry) {
|
||||
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
|
||||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase().contains(searchText)) ||
|
||||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue)) {
|
||||
matchingEntries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
|
||||
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
|
||||
for(Entry entry : purposeEntry.getChildren()) {
|
||||
if(entry instanceof NodeEntry nodeEntry) {
|
||||
if(nodeEntry.getAddress().toString().contains(searchText) ||
|
||||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase().contains(searchText)) ||
|
||||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) {
|
||||
matchingEntries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WalletUtxosEntry walletUtxosEntry = walletForm.getWalletUtxosEntry();
|
||||
for(Entry entry : walletUtxosEntry.getChildren()) {
|
||||
if(entry instanceof HashIndexEntry hashIndexEntry) {
|
||||
if(hashIndexEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
|
||||
(hashIndexEntry.getLabel() != null && hashIndexEntry.getLabel().toLowerCase().contains(searchText)) ||
|
||||
(hashIndexEntry.getValue() != null && searchValue != null && Math.abs(hashIndexEntry.getValue()) == searchValue)) {
|
||||
matchingEntries.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchWalletEntry rootEntry = new SearchWalletEntry(walletForms.iterator().next().getWallet(), matchingEntries);
|
||||
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
|
||||
results.setRoot(rootItem);
|
||||
}
|
||||
|
||||
private static class SearchWalletEntry extends Entry {
|
||||
public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
|
||||
super(wallet, wallet.getName(), entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getValue() {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntryType() {
|
||||
return "Search Wallet Results";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Function getWalletFunction() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SearchEntryCell extends EntryCell {
|
||||
@Override
|
||||
protected void updateItem(Entry entry, boolean empty) {
|
||||
super.updateItem(entry, empty);
|
||||
setContextMenu(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SearchLabelCell extends LabelCell {
|
||||
@Override
|
||||
public void updateItem(String label, boolean empty) {
|
||||
super.updateItem(label, empty);
|
||||
setContextMenu(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,18 @@ public class TextUtils {
|
||||
helper.setText(DEFAULT_TEXT);
|
||||
return d;
|
||||
}
|
||||
|
||||
public static double computeTextHeight(Font font, String text) {
|
||||
helper.setText(text);
|
||||
helper.setFont(font);
|
||||
|
||||
helper.setWrappingWidth(0.0D);
|
||||
helper.setLineSpacing(0.0D);
|
||||
double d = Math.ceil(helper.getLayoutBounds().getHeight());
|
||||
|
||||
helper.setWrappingWidth(DEFAULT_WRAPPING_WIDTH);
|
||||
helper.setLineSpacing(DEFAULT_LINE_SPACING);
|
||||
helper.setText(DEFAULT_TEXT);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 int MAX_BARS = 10;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SpecterDIY());
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new GordianSeedTool(), new SpecterDIY());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
importAccordion.getPanes().add(importPane);
|
||||
|
||||
@@ -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,26 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.wallet.Function;
|
||||
|
||||
public class FunctionActionEvent {
|
||||
private final Function function;
|
||||
private final Wallet wallet;
|
||||
|
||||
public FunctionActionEvent(Function function, Wallet wallet) {
|
||||
this.function = function;
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
public Function getFunction() {
|
||||
return function;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public boolean selectFunction() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.wallet.Function;
|
||||
import com.sparrowwallet.sparrow.wallet.NodeEntry;
|
||||
|
||||
public class ReceiveActionEvent {
|
||||
private final Wallet wallet;
|
||||
|
||||
public class ReceiveActionEvent extends FunctionActionEvent {
|
||||
public ReceiveActionEvent(NodeEntry receiveEntry) {
|
||||
this.wallet = receiveEntry.getWallet();
|
||||
super(Function.RECEIVE, receiveEntry.getWallet());
|
||||
}
|
||||
|
||||
public ReceiveActionEvent(Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
super(Function.RECEIVE, wallet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.net.Aopp;
|
||||
|
||||
public class ReceiveProofEvent {
|
||||
private final Wallet wallet;
|
||||
private final Aopp aopp;
|
||||
|
||||
public ReceiveProofEvent(Wallet wallet, Aopp aopp) {
|
||||
this.wallet = wallet;
|
||||
this.aopp = aopp;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public Aopp getAopp() {
|
||||
return aopp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
|
||||
public class SelectEntryEvent {
|
||||
private final Entry entry;
|
||||
|
||||
public SelectEntryEvent(Entry entry) {
|
||||
this.entry = entry;
|
||||
}
|
||||
|
||||
public Entry getEntry() {
|
||||
return entry;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return entry.getWallet();
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,24 @@ package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.wallet.Function;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SendActionEvent {
|
||||
private final Wallet wallet;
|
||||
public class SendActionEvent extends FunctionActionEvent {
|
||||
private final List<BlockTransactionHashIndex> utxos;
|
||||
|
||||
public SendActionEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
|
||||
this.wallet = wallet;
|
||||
super(Function.SEND, wallet);
|
||||
this.utxos = utxos;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public List<BlockTransactionHashIndex> getUtxos() {
|
||||
return utxos;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean selectFunction() {
|
||||
return !getUtxos().isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ public class SettingsChangedEvent {
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT, BIRTH_DATE;
|
||||
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT, BIRTH_DATE, WATCH_LAST;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
public class WalletDeletedEvent extends WalletChangedEvent {
|
||||
public WalletDeletedEvent(Wallet wallet) {
|
||||
super(wallet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
public class WalletWatchLastChangedEvent extends WalletSettingsChangedEvent {
|
||||
private final Integer watchLast;
|
||||
|
||||
public WalletWatchLastChangedEvent(Wallet wallet, Wallet pastWallet, String walletId, Integer watchLast) {
|
||||
super(wallet, pastWallet, walletId);
|
||||
this.watchLast = watchLast;
|
||||
}
|
||||
|
||||
public Integer getWatchLast() {
|
||||
return watchLast;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public class FontAwesome5 extends GlyphFont {
|
||||
EYE('\uf06e'),
|
||||
FEATHER_ALT('\uf56b'),
|
||||
FILE_CSV('\uf6dd'),
|
||||
FILE_IMPORT('\uf56f'),
|
||||
HAND_HOLDING('\uf4bd'),
|
||||
HAND_HOLDING_MEDICAL('\ue05c'),
|
||||
HAND_HOLDING_WATER('\uf4c1'),
|
||||
@@ -56,6 +57,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 +72,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');
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
|
||||
Wallet wallet = new Wallet();
|
||||
wallet.setName(cf.name);
|
||||
wallet.setPolicyType(PolicyType.MULTI);
|
||||
ScriptType scriptType = ScriptType.valueOf(cf.addressType);
|
||||
ScriptType scriptType = ScriptType.valueOf(cf.addressType.replace('-', '_'));
|
||||
|
||||
for(ExtPublicKey extKey : cf.extendedPublicKeys) {
|
||||
Keystore keystore = new Keystore(extKey.name);
|
||||
@@ -78,7 +78,6 @@ public class CaravanMultisig implements WalletImport, WalletExport {
|
||||
|
||||
return wallet;
|
||||
} catch(Exception e) {
|
||||
log.error("Error importing " + getName() + " wallet", e);
|
||||
throw new ImportException("Error importing " + getName() + " wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
||||
|
||||
return keystore;
|
||||
} catch (Exception e) {
|
||||
log.error("Error getting " + getName() + " keystore", e);
|
||||
throw new ImportException("Error getting " + getName() + " keystore", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -154,7 +154,6 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
|
||||
return wallet;
|
||||
} catch(Exception e) {
|
||||
log.error("Error importing " + getName() + " wallet", e);
|
||||
throw new ImportException("Error importing " + getName() + " wallet", e);
|
||||
}
|
||||
}
|
||||
@@ -197,7 +196,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
|
||||
for(Keystore keystore : wallet.getKeystores()) {
|
||||
if(multipleDerivations) {
|
||||
writer.append("# derivation: ").append(keystore.getKeyDerivation().getDerivationPath()).append("\n");
|
||||
writer.append("Derivation: ").append(keystore.getKeyDerivation().getDerivationPath()).append("\n");
|
||||
}
|
||||
writer.append(keystore.getKeyDerivation().getMasterFingerprint().toUpperCase()).append(": ").append(keystore.getExtendedPublicKey().toString()).append("\n");
|
||||
if(multipleDerivations) {
|
||||
|
||||
@@ -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
|
||||
@@ -86,7 +86,6 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error getting " + getName() + " keystore", e);
|
||||
throw new ImportException("Error getting " + getName() + " keystore", e);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.samourai.whirlpool.client.wallet.beans.IndexRange;
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.sparrow.Mode;
|
||||
import com.sparrowwallet.sparrow.Theme;
|
||||
@@ -13,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);
|
||||
|
||||
@@ -42,7 +41,6 @@ public class Config {
|
||||
private boolean hideEmptyUsedAddresses = false;
|
||||
private boolean showTransactionHex = true;
|
||||
private boolean showLoadingLog = true;
|
||||
private boolean showUtxosChart = true;
|
||||
private boolean preventSleep = false;
|
||||
private List<File> recentWalletFiles;
|
||||
private Integer keyDerivationPeriod;
|
||||
@@ -52,13 +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 String electrumServer;
|
||||
private List<String> recentElectrumServers;
|
||||
private File electrumServerCert;
|
||||
private boolean useProxy;
|
||||
private String proxyServer;
|
||||
private int maxServerTimeout = DEFAULT_MAX_TIMEOUT;
|
||||
private int maxPageSize = DEFAULT_PAGE_SIZE;
|
||||
private boolean usePayNym;
|
||||
private boolean sameAppMixing;
|
||||
private Double appWidth;
|
||||
private Double appHeight;
|
||||
|
||||
@@ -270,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;
|
||||
}
|
||||
@@ -384,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() {
|
||||
@@ -397,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;
|
||||
}
|
||||
@@ -433,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;
|
||||
}
|
||||
@@ -460,6 +499,32 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public int getMaxServerTimeout() {
|
||||
return maxServerTimeout;
|
||||
}
|
||||
|
||||
public int getMaxPageSize() {
|
||||
return maxPageSize;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -23,7 +24,7 @@ public class Descriptor implements WalletImport, WalletExport {
|
||||
@Override
|
||||
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
|
||||
try {
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet);
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null);
|
||||
String outputDescriptorString = outputDescriptor.toString(true);
|
||||
outputStream.write(outputDescriptorString.getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.flush();
|
||||
|
||||
@@ -264,7 +264,6 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
|
||||
return wallet;
|
||||
} catch (Exception e) {
|
||||
log.error("Error importing Electrum Wallet", e);
|
||||
throw new ImportException("Error importing Electrum Wallet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class GordianSeedTool implements KeystoreFileImport {
|
||||
@Override
|
||||
public boolean isEncrypted(File file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Gordian Seed Tool";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.GORDIAN_SEED_TOOL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
throw new ImportException("Only QR imports are supported.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isKeystoreImportScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Select your seed and scan the QR code created by Authenticate > Derive Key > Other Key Derivations > " + Network.get().toDisplayString() + " > Master Key > Account Descriptor. Click the share icon at the bottom.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -39,16 +39,17 @@ 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()) {
|
||||
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), "--password", passphrase, Command.ENUMERATE.toString());
|
||||
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), "--password", escape(passphrase), Command.ENUMERATE.toString());
|
||||
} else {
|
||||
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), Command.ENUMERATE.toString());
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -56,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;
|
||||
}
|
||||
@@ -166,9 +179,9 @@ public class Hwi {
|
||||
isPromptActive = true;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_MESSAGE, message, derivationPath));
|
||||
output = execute(getDeviceArguments(device, passphrase, Command.SIGN_MESSAGE), Command.SIGN_MESSAGE, message, derivationPath);
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.SIGN_MESSAGE, message, derivationPath));
|
||||
output = execute(getDeviceArguments(device, Command.SIGN_MESSAGE), Command.SIGN_MESSAGE, message, derivationPath);
|
||||
}
|
||||
|
||||
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
|
||||
@@ -196,9 +209,9 @@ public class Hwi {
|
||||
isPromptActive = true;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_TX, psbtBase64));
|
||||
output = execute(getDeviceArguments(device, passphrase, Command.SIGN_TX), Command.SIGN_TX, psbtBase64);
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.SIGN_TX, psbtBase64));
|
||||
output = execute(getDeviceArguments(device, Command.SIGN_TX), Command.SIGN_TX, psbtBase64);
|
||||
}
|
||||
|
||||
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
|
||||
@@ -220,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;
|
||||
}
|
||||
@@ -229,7 +242,31 @@ public class Hwi {
|
||||
private String execute(List<String> command) throws IOException {
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
Process process = processBuilder.start();
|
||||
return CharStreams.toString(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
|
||||
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
|
||||
return CharStreams.toString(reader);
|
||||
}
|
||||
}
|
||||
|
||||
private String execute(List<String> arguments, Command command, String... commandArguments) throws IOException {
|
||||
List<String> processArguments = new ArrayList<>(arguments);
|
||||
processArguments.add("--stdin");
|
||||
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(processArguments);
|
||||
Process process = processBuilder.start();
|
||||
|
||||
try(BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8))) {
|
||||
writer.write(command.toString());
|
||||
for(String commandArgument : commandArguments) {
|
||||
writer.write(" \"");
|
||||
writer.write(commandArgument.replace("\\", "\\\\").replace("\"", "\\\""));
|
||||
writer.write("\"");
|
||||
}
|
||||
writer.flush();
|
||||
}
|
||||
|
||||
try(InputStreamReader reader = new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)) {
|
||||
return CharStreams.toString(reader);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized File getHwiExecutable(Command command) {
|
||||
@@ -387,30 +424,42 @@ public class Hwi {
|
||||
return result.get("success").getAsBoolean();
|
||||
}
|
||||
|
||||
private List<String> getDeviceArguments(Device device, Command command) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType()));
|
||||
addChainType(elements, false);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private List<String> getDeviceArguments(Device device, String passphrase, Command command) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), "--password", escape(passphrase)));
|
||||
addChainType(elements, false);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private List<String> getDeviceCommand(Device device, Command command) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
|
||||
addChainType(elements);
|
||||
addChainType(elements, true);
|
||||
return elements;
|
||||
}
|
||||
|
||||
private List<String> getDeviceCommand(Device device, Command command, String... commandData) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
|
||||
addChainType(elements);
|
||||
addChainType(elements, true);
|
||||
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
|
||||
return elements;
|
||||
}
|
||||
|
||||
private List<String> getDeviceCommand(Device device, String passphrase, Command command, String... commandData) throws IOException {
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), "--password", passphrase, command.toString()));
|
||||
addChainType(elements);
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), "--password", escape(passphrase), command.toString()));
|
||||
addChainType(elements, true);
|
||||
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
|
||||
return elements;
|
||||
}
|
||||
|
||||
private void addChainType(List<String> elements) {
|
||||
private void addChainType(List<String> elements, boolean commandPresent) {
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--chain");
|
||||
elements.add(elements.size() - 1, getChainName(Network.get()));
|
||||
elements.add(elements.size() - (commandPresent ? 1 : 0), "--chain");
|
||||
elements.add(elements.size() - (commandPresent ? 1 : 0), getChainName(Network.get()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +473,15 @@ public class Hwi {
|
||||
return network.toString();
|
||||
}
|
||||
|
||||
private String escape(String passphrase) {
|
||||
Platform platform = Platform.getCurrent();
|
||||
if(platform == Platform.WINDOWS) {
|
||||
return passphrase.replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
return passphrase;
|
||||
}
|
||||
|
||||
public static class EnumerateService extends Service<List<Device>> {
|
||||
private final String passphrase;
|
||||
|
||||
|
||||
@@ -62,10 +62,8 @@ public class KeystoneSinglesig implements KeystoreFileImport, WalletImport {
|
||||
|
||||
return keystore;
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Error getting " + getName() + " keystore - not an output descriptor");
|
||||
throw new ImportException("Error getting " + getName() + " keystore", e);
|
||||
throw new ImportException("Error getting " + getName() + " keystore - not an output descriptor", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Error getting " + getName() + " keystore", e);
|
||||
throw new ImportException("Error getting " + getName() + " keystore", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ import java.io.InputStream;
|
||||
public interface KeystoreFileImport extends KeystoreImport, FileImport {
|
||||
Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException;
|
||||
boolean isKeystoreImportScannable();
|
||||
default boolean isFileFormatAvailable() {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class SeedSigner extends SpecterDIY {
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports multisig wallets with a P2WSH script type.";
|
||||
return "Import QR created on your SeedSigner by selecting xPub from Seed in the Seed Tools menu once you have entered your seed.";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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();
|
||||
@@ -118,7 +121,6 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
|
||||
return wallet;
|
||||
} catch(IOException | StorageException e) {
|
||||
log.error("Error importing Sparrow wallet", e);
|
||||
throw new ImportException("Error importing Sparrow wallet", e);
|
||||
} finally {
|
||||
if(storage != null) {
|
||||
|
||||
@@ -35,7 +35,6 @@ public class SpecterDIY implements KeystoreFileImport, WalletExport {
|
||||
|
||||
return keystore;
|
||||
} catch(IOException e) {
|
||||
log.error("Error getting " + getName() + " keystore", e);
|
||||
throw new ImportException("Error getting " + getName() + " keystore", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ public class SpecterDesktop implements WalletImport, WalletExport {
|
||||
return wallet;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Error importing " + getName() + " wallet", e);
|
||||
throw new ImportException("Error importing " + getName() + " wallet", e);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == '{') {
|
||||
@@ -529,6 +534,11 @@ public class Storage {
|
||||
private final Storage storage;
|
||||
private final SecureString password;
|
||||
|
||||
public LoadWalletService(Storage storage) {
|
||||
this.storage = storage;
|
||||
this.password = null;
|
||||
}
|
||||
|
||||
public LoadWalletService(Storage storage, SecureString password) {
|
||||
this.storage = storage;
|
||||
this.password = password;
|
||||
@@ -538,8 +548,15 @@ public class Storage {
|
||||
protected Task<WalletBackupAndKey> createTask() {
|
||||
return new Task<>() {
|
||||
protected WalletBackupAndKey call() throws IOException, StorageException {
|
||||
WalletBackupAndKey walletBackupAndKey = storage.loadEncryptedWallet(password);
|
||||
password.clear();
|
||||
WalletBackupAndKey walletBackupAndKey;
|
||||
|
||||
if(password != null) {
|
||||
walletBackupAndKey = storage.loadEncryptedWallet(password);
|
||||
password.clear();
|
||||
} else {
|
||||
walletBackupAndKey = storage.loadUnencryptedWallet();
|
||||
}
|
||||
|
||||
return walletBackupAndKey;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.sparrowwallet.sparrow.wallet.*;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import com.zaxxer.hikari.pool.HikariPool;
|
||||
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.FlywayException;
|
||||
import org.flywaydb.core.api.exception.FlywayValidateException;
|
||||
@@ -33,6 +34,9 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -55,6 +59,7 @@ public class DbPersistence implements Persistence {
|
||||
|
||||
private Wallet masterWallet;
|
||||
private final Map<Wallet, DirtyPersistables> dirtyPersistablesMap = new HashMap<>();
|
||||
private ExecutorService updateExecutor;
|
||||
|
||||
public DbPersistence() {
|
||||
EventManager.get().register(this);
|
||||
@@ -94,6 +99,8 @@ public class DbPersistence implements Persistence {
|
||||
Map<WalletBackupAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, backupWallet, encryptionKey);
|
||||
masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
|
||||
|
||||
createUpdateExecutor(masterWallet);
|
||||
|
||||
return new WalletBackupAndKey(masterWallet, backupWallet, encryptionKey, keyDeriver, childWallets);
|
||||
}
|
||||
|
||||
@@ -157,7 +164,21 @@ public class DbPersistence implements Persistence {
|
||||
@Override
|
||||
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws StorageException {
|
||||
updatePassword(storage, encryptionPubKey);
|
||||
update(storage, wallet, getFilePassword(encryptionPubKey));
|
||||
|
||||
updateExecutor.execute(() -> {
|
||||
try {
|
||||
update(storage, wallet, getFilePassword(encryptionPubKey));
|
||||
} catch(Exception e) {
|
||||
log.error("Error updating wallet db", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized void createUpdateExecutor(Wallet masterWallet) {
|
||||
if(updateExecutor == null) {
|
||||
BasicThreadFactory factory = new BasicThreadFactory.Builder().namingPattern(masterWallet.getFullName() + "-dbupdater").daemon(false).priority(Thread.NORM_PRIORITY).build();
|
||||
updateExecutor = Executors.newSingleThreadExecutor(factory);
|
||||
}
|
||||
}
|
||||
|
||||
private File renameToDbFile(File walletFile) throws IOException {
|
||||
@@ -196,6 +217,11 @@ public class DbPersistence implements Persistence {
|
||||
jdbi.useHandle(handle -> {
|
||||
WalletDao walletDao = handle.attach(WalletDao.class);
|
||||
try {
|
||||
if(dirtyPersistables.deleteAccount && !wallet.isMasterWallet()) {
|
||||
handle.execute("drop schema `" + getSchema(wallet) + "` cascade");
|
||||
return;
|
||||
}
|
||||
|
||||
walletDao.setSchema(getSchema(wallet));
|
||||
|
||||
if(dirtyPersistables.clearHistory) {
|
||||
@@ -248,6 +274,10 @@ public class DbPersistence implements Persistence {
|
||||
walletDao.updateGapLimit(wallet.getId(), dirtyPersistables.gapLimit);
|
||||
}
|
||||
|
||||
if(dirtyPersistables.watchLast != null) {
|
||||
walletDao.updateWatchLast(wallet.getId(), dirtyPersistables.watchLast);
|
||||
}
|
||||
|
||||
if(!dirtyPersistables.labelEntries.isEmpty()) {
|
||||
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
|
||||
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
|
||||
@@ -332,6 +362,7 @@ public class DbPersistence implements Persistence {
|
||||
|
||||
if(wallet.isMasterWallet()) {
|
||||
masterWallet = wallet;
|
||||
createUpdateExecutor(masterWallet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +575,24 @@ public class DbPersistence implements Persistence {
|
||||
@Override
|
||||
public void close() {
|
||||
EventManager.get().unregister(this);
|
||||
if(updateExecutor != null) {
|
||||
updateExecutor.shutdown();
|
||||
try {
|
||||
if(!updateExecutor.awaitTermination(1, TimeUnit.MINUTES)) {
|
||||
updateExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
closeDataSource();
|
||||
} catch (InterruptedException e) {
|
||||
updateExecutor.shutdownNow();
|
||||
closeDataSource();
|
||||
}
|
||||
} else {
|
||||
closeDataSource();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeDataSource() {
|
||||
if(dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
}
|
||||
@@ -631,90 +680,108 @@ public class DbPersistence implements Persistence {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletDeleted(WalletDeletedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).deleteAccount = true);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryCleared(WalletHistoryClearedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).clearHistory = true;
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).clearHistory = true);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).historyNodes.addAll(event.getHistoryChangedNodes());
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).historyNodes.addAll(event.getHistoryChangedNodes()));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletLabelChanged(WalletLabelChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).label = event.getLabel();
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).label = event.getLabel());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletBlockHeightChanged(WalletBlockHeightChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).blockHeight = event.getBlockHeight();
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).blockHeight = event.getBlockHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletGapLimitChanged(WalletGapLimitChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).gapLimit = event.getGapLimit();
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).gapLimit = event.getGapLimit());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletEntryLabelsChanged(WalletEntryLabelsChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelEntries.addAll(event.getEntries());
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelEntries.addAll(event.getEntries()));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).utxoStatuses.addAll(event.getUtxos());
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).utxoStatuses.addAll(event.getUtxos()));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletMixConfigChanged(WalletMixConfigChangedEvent event) {
|
||||
if(persistsFor(event.getWallet()) && event.getWallet().getMixConfig() != null) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).mixConfig = true;
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).mixConfig = true);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletUtxoMixesChanged(WalletUtxoMixesChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).changedUtxoMixes.putAll(event.getChangedUtxoMixes());
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).removedUtxoMixes.putAll(event.getRemovedUtxoMixes());
|
||||
updateExecutor.execute(() -> {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).changedUtxoMixes.putAll(event.getChangedUtxoMixes());
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).removedUtxoMixes.putAll(event.getRemovedUtxoMixes());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelKeystores.addAll(event.getChangedKeystores());
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelKeystores.addAll(event.getChangedKeystores()));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void keystoreEncryptionChanged(KeystoreEncryptionChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).encryptionKeystores.addAll(event.getChangedKeystores());
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).encryptionKeystores.addAll(event.getChangedKeystores()));
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletWatchLastChanged(WalletWatchLastChangedEvent event) {
|
||||
if(persistsFor(event.getWallet())) {
|
||||
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).watchLast = event.getWatchLast());
|
||||
}
|
||||
}
|
||||
|
||||
private static class DirtyPersistables {
|
||||
public boolean deleteAccount;
|
||||
public boolean clearHistory;
|
||||
public final List<WalletNode> historyNodes = new ArrayList<>();
|
||||
public String label;
|
||||
public Integer blockHeight = null;
|
||||
public Integer gapLimit = null;
|
||||
public Integer watchLast = null;
|
||||
public final List<Entry> labelEntries = new ArrayList<>();
|
||||
public final List<BlockTransactionHashIndex> utxoStatuses = new ArrayList<>();
|
||||
public boolean mixConfig;
|
||||
@@ -725,11 +792,13 @@ public class DbPersistence implements Persistence {
|
||||
|
||||
public String toString() {
|
||||
return "Dirty Persistables" +
|
||||
"\nDelete account:" + deleteAccount +
|
||||
"\nClear history:" + clearHistory +
|
||||
"\nNodes:" + historyNodes +
|
||||
"\nLabel:" + label +
|
||||
"\nBlockHeight:" + blockHeight +
|
||||
"\nGap limit:" + gapLimit +
|
||||
"\nWatch last:" + watchLast +
|
||||
"\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()) +
|
||||
|
||||
@@ -36,21 +36,21 @@ public interface WalletDao {
|
||||
@CreateSqlObject
|
||||
UtxoMixDataDao createUtxoMixDataDao();
|
||||
|
||||
@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")
|
||||
@SqlQuery("select wallet.id, wallet.name, wallet.label, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.watchLast, 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.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")
|
||||
@SqlQuery("select wallet.id, wallet.name, wallet.label, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.watchLast, 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.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")
|
||||
@SqlQuery("select wallet.id, wallet.name, wallet.label, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.watchLast, 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, label, network, policyType, scriptType, storedBlockHeight, gapLimit, birthDate, defaultPolicy) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
@SqlUpdate("insert into wallet (name, label, network, policyType, scriptType, storedBlockHeight, gapLimit, watchLast, birthDate, defaultPolicy) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
@GetGeneratedKeys("id")
|
||||
long insert(String name, String label, 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, Integer watchLast, Date birthDate, long defaultPolicy);
|
||||
|
||||
@SqlUpdate("update wallet set label = :label where id = :id")
|
||||
void updateLabel(@Bind("id") long id, @Bind("label") String label);
|
||||
@@ -61,6 +61,9 @@ public interface WalletDao {
|
||||
@SqlUpdate("update wallet set gapLimit = :gapLimit where id = :id")
|
||||
void updateGapLimit(@Bind("id") long id, @Bind("gapLimit") Integer gapLimit);
|
||||
|
||||
@SqlUpdate("update wallet set watchLast = :watchLast where id = :id")
|
||||
void updateWatchLast(@Bind("id") long id, @Bind("watchLast") Integer watchLast);
|
||||
|
||||
@SqlUpdate("set schema ?")
|
||||
int setSchema(String schema);
|
||||
|
||||
@@ -111,7 +114,7 @@ public interface WalletDao {
|
||||
setSchema(schema);
|
||||
createPolicyDao().addPolicy(wallet.getDefaultPolicy());
|
||||
|
||||
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());
|
||||
long id = insert(truncate(wallet.getName()), truncate(wallet.getLabel()), wallet.getNetwork().ordinal(), wallet.getPolicyType().ordinal(), wallet.getScriptType().ordinal(), wallet.getStoredBlockHeight(), wallet.gapLimit(), wallet.getWatchLast(), wallet.getBirthDate(), wallet.getDefaultPolicy().getId());
|
||||
wallet.setId(id);
|
||||
|
||||
createKeystoreDao().addKeystores(wallet);
|
||||
|
||||
@@ -31,6 +31,8 @@ public class WalletMapper implements RowMapper<Wallet> {
|
||||
|
||||
int gapLimit = rs.getInt("wallet.gapLimit");
|
||||
wallet.gapLimit(rs.wasNull() ? null : gapLimit);
|
||||
int watchLast = rs.getInt("wallet.watchLast");
|
||||
wallet.setWatchLast(rs.wasNull() ? null : watchLast);
|
||||
wallet.setBirthDate(rs.getTimestamp("wallet.birthDate"));
|
||||
|
||||
return wallet;
|
||||
|
||||
@@ -18,9 +18,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
public void initializeView() {
|
||||
List<KeystoreFileImport> importers = Collections.emptyList();
|
||||
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
|
||||
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new SpecterDIY());
|
||||
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
||||
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new SpecterDIY());
|
||||
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
||||
}
|
||||
|
||||
for(KeystoreFileImport importer : importers) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class Aopp {
|
||||
private static final Logger log = LoggerFactory.getLogger(Aopp.class);
|
||||
|
||||
public static final String SCHEME = "aopp";
|
||||
|
||||
private final int version;
|
||||
private final String message;
|
||||
private final ScriptType scriptType;
|
||||
private final URL callback;
|
||||
|
||||
public Aopp(URI uri) throws MalformedURLException {
|
||||
if(!uri.getScheme().equals(SCHEME)) {
|
||||
throw new IllegalArgumentException("Uri " + uri + " does not have the correct scheme");
|
||||
}
|
||||
|
||||
Map<String, String> parameterMap = new LinkedHashMap<>();
|
||||
String query = uri.getSchemeSpecificPart();
|
||||
if(query.startsWith("?")) {
|
||||
query = query.substring(1);
|
||||
}
|
||||
|
||||
String[] pairs = query.split("&");
|
||||
for(String pair : pairs) {
|
||||
int idx = pair.indexOf("=");
|
||||
parameterMap.put(pair.substring(0, idx), pair.substring(idx + 1));
|
||||
}
|
||||
|
||||
String strVersion = parameterMap.get("v");
|
||||
if(strVersion == null) {
|
||||
throw new IllegalArgumentException("No version parameter provided");
|
||||
}
|
||||
|
||||
version = Integer.parseInt(strVersion);
|
||||
if(version != 0) {
|
||||
throw new IllegalArgumentException("Unsupported version number " + version);
|
||||
}
|
||||
|
||||
String strMessage = parameterMap.get("msg");
|
||||
if(strMessage == null) {
|
||||
throw new IllegalArgumentException("No msg parameter provided");
|
||||
}
|
||||
message = strMessage.replace('+', ' ');
|
||||
|
||||
String asset = parameterMap.get("asset");
|
||||
if(asset == null || !asset.equals("btc")) {
|
||||
throw new IllegalArgumentException("Unsupported asset type " + asset);
|
||||
}
|
||||
|
||||
String format = parameterMap.get("format");
|
||||
if(format == null) {
|
||||
throw new IllegalArgumentException("No format parameter provided");
|
||||
}
|
||||
if(format.equals("p2sh")) {
|
||||
format = "p2sh_p2wpkh";
|
||||
}
|
||||
|
||||
if(format.equals("any")) {
|
||||
scriptType = null;
|
||||
} else {
|
||||
scriptType = ScriptType.valueOf(format.toUpperCase());
|
||||
}
|
||||
|
||||
String callbackUrl = parameterMap.get("callback");
|
||||
if(callbackUrl == null) {
|
||||
throw new IllegalArgumentException("No callback parameter provided");
|
||||
}
|
||||
|
||||
callback = new URL(callbackUrl);
|
||||
}
|
||||
|
||||
public void sendProofOfAddress(Address address, String signature) throws URISyntaxException, IOException, InterruptedException, AoppException {
|
||||
Proxy proxy = AppServices.getProxy();
|
||||
|
||||
CallbackRequest callbackRequest = new CallbackRequest(version, address.toString(), signature);
|
||||
Gson gson = new GsonBuilder().disableHtmlEscaping().create();
|
||||
String json = gson.toJson(callbackRequest);
|
||||
|
||||
if(log.isDebugEnabled()) {
|
||||
log.debug("Sending " + json + " to " + callback);
|
||||
}
|
||||
|
||||
HttpURLConnection connection = proxy == null ? (HttpURLConnection)callback.openConnection() : (HttpURLConnection)callback.openConnection(proxy);
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setDoOutput(true);
|
||||
|
||||
try(OutputStream os = connection.getOutputStream()) {
|
||||
byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(jsonBytes);
|
||||
}
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
try(BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String responseLine;
|
||||
while((responseLine = br.readLine()) != null) {
|
||||
response.append(responseLine.trim());
|
||||
}
|
||||
}
|
||||
int statusCode = connection.getResponseCode();
|
||||
|
||||
if(log.isDebugEnabled()) {
|
||||
log.debug("Received " + statusCode + " " + response);
|
||||
}
|
||||
|
||||
if(statusCode < 200 || statusCode >= 300) {
|
||||
throw new AoppException("Could not send proof of ownership. Server returned " + response);
|
||||
}
|
||||
}
|
||||
|
||||
public ScriptType getScriptType() {
|
||||
return scriptType;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public URL getCallback() {
|
||||
return callback;
|
||||
}
|
||||
|
||||
private static class CallbackRequest {
|
||||
public CallbackRequest(int version, String address, String signature) {
|
||||
this.version = version;
|
||||
this.address = address;
|
||||
this.signature = signature;
|
||||
}
|
||||
|
||||
public int version;
|
||||
public String address;
|
||||
public String signature;
|
||||
}
|
||||
|
||||
public static final class AoppException extends Exception {
|
||||
public AoppException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AoppException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public AoppException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public AoppException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
|
||||
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.protocol.Sha256Hash;
|
||||
@@ -21,16 +20,24 @@ 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 DEFAULT_MAX_ATTEMPTS = 5;
|
||||
static final int RETRY_DELAY_SECS = 1;
|
||||
|
||||
private final AtomicLong idCounter = new AtomicLong();
|
||||
private final AtomicLong idCounter;
|
||||
|
||||
public BatchedElectrumServerRpc() {
|
||||
this.idCounter = new AtomicLong();
|
||||
}
|
||||
|
||||
public BatchedElectrumServerRpc(long idCounterValue) {
|
||||
this.idCounter = new AtomicLong(idCounterValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ping(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
new RetryLogic<>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
new RetryLogic<>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error pinging server", e);
|
||||
@@ -41,7 +48,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<List<String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
return new RetryLogic<List<String>>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).param("client_name", clientName).param("protocol_version", supportedVersions).execute());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error getting server version", e);
|
||||
@@ -52,7 +59,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public String getServerBanner(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
return new RetryLogic<String>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("server.banner").id(idCounter.incrementAndGet()).execute());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error getting server banner", e);
|
||||
@@ -63,7 +70,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public BlockHeaderTip subscribeBlockHeaders(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<BlockHeaderTip>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
return new RetryLogic<BlockHeaderTip>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(idCounter.incrementAndGet()).execute());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error subscribing to block headers", e);
|
||||
@@ -73,8 +80,7 @@ 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);
|
||||
PagedBatchRequestBuilder<String, ScriptHashTx[]> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(String.class).returnType(ScriptHashTx[].class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + nodeRangesToString(pathScriptHashes.keySet())));
|
||||
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
@@ -82,7 +88,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
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: " + nodeRangesToString((Collection<String>)e.getErrors().keySet()), e);
|
||||
@@ -102,15 +108,14 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
@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, idCounter).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: " + nodeRangesToString((Collection<String>)e.getErrors().keySet()), e);
|
||||
@@ -130,8 +135,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
@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);
|
||||
PagedBatchRequestBuilder<String, String> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(String.class).returnType(String.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + nodeRangesToString(pathScriptHashes.keySet())));
|
||||
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
@@ -139,7 +143,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
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: " + nodeRangesToString((Collection<String>)e.getErrors().keySet()), e);
|
||||
@@ -151,8 +155,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
@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, idCounter).keysType(Integer.class).returnType(String.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving " + blockHeights.size() + " block headers"));
|
||||
|
||||
for(Integer height : blockHeights) {
|
||||
@@ -160,7 +163,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 +174,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, idCounter).keysType(String.class).returnType(String.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving " + txids.size() + " transactions"));
|
||||
|
||||
for(String txid : txids) {
|
||||
@@ -180,7 +182,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();
|
||||
|
||||
@@ -199,15 +201,14 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BatchRequestBuilder<String, VerboseTransaction> batchRequest = client.createBatchRequest().keysType(String.class).returnType(VerboseTransaction.class);
|
||||
PagedBatchRequestBuilder<String, VerboseTransaction> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(String.class).returnType(VerboseTransaction.class);
|
||||
for(String txid : txids) {
|
||||
batchRequest.add(txid, "blockchain.transaction.get", txid, true);
|
||||
}
|
||||
|
||||
try {
|
||||
//The server may return an error if the transaction has not yet been broadcasted - this is a valid state so only try once
|
||||
return new RetryLogic<Map<String, VerboseTransaction>>(1, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
return batchRequest.execute(1);
|
||||
} catch(JsonRpcBatchException e) {
|
||||
log.debug("Some errors retrieving transactions: " + e.getErrors());
|
||||
return (Map<String, VerboseTransaction>)e.getSuccesses();
|
||||
@@ -218,14 +219,13 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
|
||||
@Override
|
||||
public Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BatchRequestBuilder<Integer, Double> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(Double.class);
|
||||
PagedBatchRequestBuilder<Integer, Double> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(Integer.class).returnType(Double.class);
|
||||
for(Integer targetBlock : targetBlocks) {
|
||||
batchRequest.add(targetBlock, "blockchain.estimatefee", targetBlock);
|
||||
}
|
||||
|
||||
try {
|
||||
return new RetryLogic<Map<Integer, Double>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
return batchRequest.execute();
|
||||
} catch(JsonRpcBatchException e) {
|
||||
throw new ElectrumServerRpcException("Error getting fee estimates", e);
|
||||
} catch(Exception e) {
|
||||
@@ -237,7 +237,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public Map<Long, Long> getFeeRateHistogram(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BigInteger[][] feesArray = new RetryLogic<BigInteger[][]>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
BigInteger[][] feesArray = new RetryLogic<BigInteger[][]>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(BigInteger[][].class).method("mempool.get_fee_histogram").id(idCounter.incrementAndGet()).execute());
|
||||
|
||||
Map<Long, Long> feeRateHistogram = new TreeMap<>();
|
||||
@@ -257,7 +257,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public Double getMinimumRelayFee(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<Double>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
return new RetryLogic<Double>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(Double.class).method("blockchain.relayfee").id(idCounter.incrementAndGet()).execute());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error getting minimum relay fee", e);
|
||||
@@ -268,7 +268,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public String broadcastTransaction(Transport transport, String txHex) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
return new RetryLogic<String>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).params(txHex).execute());
|
||||
} catch(JsonRpcException e) {
|
||||
throw new ElectrumServerRpcException(e.getErrorMessage().getMessage(), e);
|
||||
@@ -276,4 +276,9 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
throw new ElectrumServerRpcException("Error broadcasting transaction", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getIdCounterValue() {
|
||||
return idCounter.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import com.google.common.net.HostAndPort;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.SendController;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
@@ -38,6 +38,8 @@ public class ElectrumServer {
|
||||
|
||||
private static final Version ELECTRS_MIN_BATCHING_VERSION = new Version("0.9.0");
|
||||
|
||||
private static final Version FULCRUM_MIN_BATCHING_VERSION = new Version("1.6.0");
|
||||
|
||||
private static final int MINIMUM_BROADCASTS = 2;
|
||||
|
||||
public static final BlockTransaction UNFETCHABLE_BLOCK_TRANSACTION = new BlockTransaction(Sha256Hash.ZERO_HASH, 0, null, null, null);
|
||||
@@ -166,9 +168,15 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
public static void addCalculatedScriptHashes(Wallet wallet) {
|
||||
calculateScriptHashes(wallet, KeyPurpose.RECEIVE).forEach(retrievedScriptHashes::putIfAbsent);
|
||||
calculateScriptHashes(wallet, KeyPurpose.CHANGE).forEach(retrievedScriptHashes::putIfAbsent);
|
||||
private static void addCalculatedScriptHashes(Wallet wallet) {
|
||||
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) {
|
||||
@@ -292,7 +300,7 @@ public class ElectrumServer {
|
||||
public void getHistory(Wallet wallet, KeyPurpose keyPurpose, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
|
||||
WalletNode purposeNode = wallet.getNode(keyPurpose);
|
||||
//Subscribe to all existing address WalletNodes and add them to nodeTransactionMap as keys to empty sets if they have history that needs to be fetched
|
||||
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
|
||||
subscribeWalletNodes(wallet, getAddressNodes(wallet, purposeNode), nodeTransactionMap, 0);
|
||||
//All WalletNode keys in nodeTransactionMap need to have their history fetched (nodes without history will not be keys in the map yet)
|
||||
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, 0);
|
||||
//Fetch all referenced transaction to wallet transactions map. We do this now even though it is done again later to get it done before too many script hashes are subscribed
|
||||
@@ -303,7 +311,7 @@ public class ElectrumServer {
|
||||
log.debug("Fetched history for: " + nodeTransactionMap.keySet());
|
||||
|
||||
//Set the remaining WalletNode keys in nodeTransactionMap to empty sets to indicate no history (if no script hash history has already been retrieved in a previous call)
|
||||
purposeNode.getChildren().stream().filter(node -> !nodeTransactionMap.containsKey(node) && retrievedScriptHashes.get(getScriptHash(wallet, node)) == null).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
|
||||
getAddressNodes(wallet, purposeNode).stream().filter(node -> !nodeTransactionMap.containsKey(node) && retrievedScriptHashes.get(getScriptHash(wallet, node)) == null).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
|
||||
}
|
||||
|
||||
private void getHistoryToGapLimit(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, WalletNode purposeNode) throws ServerException {
|
||||
@@ -313,7 +321,7 @@ public class ElectrumServer {
|
||||
int gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
|
||||
while(historySize < gapLimitSize) {
|
||||
purposeNode.fillToIndex(gapLimitSize - 1);
|
||||
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, historySize);
|
||||
subscribeWalletNodes(wallet, getAddressNodes(wallet, purposeNode), nodeTransactionMap, historySize);
|
||||
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, historySize);
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
historySize = purposeNode.getChildren().size();
|
||||
@@ -321,6 +329,17 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
private Set<WalletNode> getAddressNodes(Wallet wallet, WalletNode purposeNode) {
|
||||
Integer watchLast = wallet.getWatchLast();
|
||||
if(watchLast == null || watchLast < wallet.getGapLimit() || wallet.getStoredBlockHeight() == 0 || wallet.getTransactions().isEmpty()) {
|
||||
return purposeNode.getChildren();
|
||||
}
|
||||
|
||||
int highestUsedIndex = purposeNode.getChildren().stream().filter(WalletNode::isUsed).mapToInt(WalletNode::getIndex).max().orElse(0);
|
||||
int startFromIndex = highestUsedIndex - watchLast;
|
||||
return purposeNode.getChildren().stream().filter(walletNode -> walletNode.getIndex() >= startFromIndex).collect(Collectors.toCollection(TreeSet::new));
|
||||
}
|
||||
|
||||
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
|
||||
int highestIndex = nodeTransactionMap.keySet().stream().map(WalletNode::getIndex).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
|
||||
return highestIndex + wallet.getGapLimit() + 1;
|
||||
@@ -841,6 +860,47 @@ public class ElectrumServer {
|
||||
return mempoolScriptHashes;
|
||||
}
|
||||
|
||||
public List<TransactionOutput> getUtxos(Address address) throws ServerException {
|
||||
Wallet wallet = new Wallet(address.toString());
|
||||
Map<String, String> pathScriptHashes = new HashMap<>();
|
||||
pathScriptHashes.put("m/0", getScriptHash(address));
|
||||
Map<String, ScriptHashTx[]> historyResult = electrumServerRpc.getScriptHashHistory(getTransport(), wallet, pathScriptHashes, true);
|
||||
Set<String> txids = Arrays.stream(historyResult.get("m/0")).map(scriptHashTx -> scriptHashTx.tx_hash).collect(Collectors.toSet());
|
||||
|
||||
Map<String, String> transactionsResult = electrumServerRpc.getTransactions(getTransport(), wallet, txids);
|
||||
List<TransactionOutput> transactionOutputs = new ArrayList<>();
|
||||
Script outputScript = address.getOutputScript();
|
||||
String strErrorTx = Sha256Hash.ZERO_HASH.toString();
|
||||
List<Transaction> transactions = new ArrayList<>();
|
||||
for(String txid : transactionsResult.keySet()) {
|
||||
String strRawTx = transactionsResult.get(txid);
|
||||
|
||||
if(strRawTx.equals(strErrorTx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(Utils.hexToBytes(strRawTx));
|
||||
for(TransactionOutput txOutput : transaction.getOutputs()) {
|
||||
if(txOutput.getScript().equals(outputScript)) {
|
||||
transactionOutputs.add(txOutput);
|
||||
}
|
||||
}
|
||||
transactions.add(transaction);
|
||||
} catch(ProtocolException e) {
|
||||
log.error("Could not parse tx: " + strRawTx);
|
||||
}
|
||||
}
|
||||
|
||||
for(Transaction transaction : transactions) {
|
||||
for(TransactionInput txInput : transaction.getInputs()) {
|
||||
transactionOutputs.removeIf(txOutput -> txOutput.getHash().equals(txInput.getOutpoint().getHash()) && txOutput.getIndex() == txInput.getOutpoint().getIndex());
|
||||
}
|
||||
}
|
||||
|
||||
return transactionOutputs;
|
||||
}
|
||||
|
||||
public static Map<String, WalletNode> getAllScriptHashes(Wallet wallet) {
|
||||
Map<String, WalletNode> scriptHashes = new HashMap<>();
|
||||
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
|
||||
@@ -864,6 +924,12 @@ public class ElectrumServer {
|
||||
return Utils.bytesToHex(reversed);
|
||||
}
|
||||
|
||||
public static String getScriptHash(Address address) {
|
||||
byte[] hash = Sha256Hash.hash(address.getOutputScript().getProgram());
|
||||
byte[] reversed = Utils.reverseBytes(hash);
|
||||
return Utils.bytesToHex(reversed);
|
||||
}
|
||||
|
||||
public static Map<String, List<String>> getSubscribedScriptHashes() {
|
||||
return subscribedScriptHashes;
|
||||
}
|
||||
@@ -904,6 +970,22 @@ public class ElectrumServer {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
if(server.startsWith("fulcrum")) {
|
||||
String fulcrumVersion = server.substring("fulcrum".length()).trim();
|
||||
int dashIndex = fulcrumVersion.indexOf('-');
|
||||
if(dashIndex > -1) {
|
||||
fulcrumVersion = fulcrumVersion.substring(0, dashIndex);
|
||||
}
|
||||
try {
|
||||
Version version = new Version(fulcrumVersion);
|
||||
if(version.compareTo(FULCRUM_MIN_BATCHING_VERSION) >= 0) {
|
||||
return true;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1019,7 +1101,7 @@ public class ElectrumServer {
|
||||
//If electrumx is detected, we can upgrade to batched RPC. Electrs/EPS do not support batching.
|
||||
if(supportsBatching(serverVersion)) {
|
||||
log.debug("Upgrading to batched JSON-RPC");
|
||||
electrumServerRpc = new BatchedElectrumServerRpc();
|
||||
electrumServerRpc = new BatchedElectrumServerRpc(electrumServerRpc.getIdCounterValue());
|
||||
}
|
||||
|
||||
BlockHeaderTip tip;
|
||||
@@ -1032,7 +1114,7 @@ public class ElectrumServer {
|
||||
|
||||
String banner = electrumServer.getServerBanner();
|
||||
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE);
|
||||
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
|
||||
feeRatesRetrievedAt = System.currentTimeMillis();
|
||||
|
||||
@@ -1048,7 +1130,7 @@ public class ElectrumServer {
|
||||
|
||||
long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt;
|
||||
if(elapsed > FEE_RATES_PERIOD) {
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE);
|
||||
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
|
||||
feeRatesRetrievedAt = System.currentTimeMillis();
|
||||
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
|
||||
@@ -1184,18 +1266,38 @@ public class ElectrumServer {
|
||||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() throws ServerException {
|
||||
walletSynchronizeLocks.putIfAbsent(wallet, new Object());
|
||||
boolean initial = (walletSynchronizeLocks.putIfAbsent(wallet, new Object()) == null);
|
||||
synchronized(walletSynchronizeLocks.get(wallet)) {
|
||||
if(initial) {
|
||||
addCalculatedScriptHashes(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
|
||||
@@ -1408,7 +1510,7 @@ public class ElectrumServer {
|
||||
return new Task<>() {
|
||||
protected FeeRatesUpdatedEvent call() throws ServerException {
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE);
|
||||
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
|
||||
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
|
||||
}
|
||||
@@ -1459,4 +1561,22 @@ public class ElectrumServer {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class AddressUtxosService extends Service<List<TransactionOutput>> {
|
||||
private final Address address;
|
||||
|
||||
public AddressUtxosService(Address address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<List<TransactionOutput>> createTask() {
|
||||
return new Task<>() {
|
||||
protected List<TransactionOutput> call() throws ServerException {
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
return electrumServer.getUtxos(address);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,4 +35,6 @@ public interface ElectrumServerRpc {
|
||||
Double getMinimumRelayFee(Transport transport);
|
||||
|
||||
String broadcastTransaction(Transport transport, String txHex);
|
||||
|
||||
long getIdCounterValue();
|
||||
}
|
||||
|
||||
@@ -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,201 @@
|
||||
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.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
|
||||
import com.github.arteam.simplejsonrpc.core.domain.ErrorMessage;
|
||||
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 java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static com.sparrowwallet.sparrow.net.BatchedElectrumServerRpc.DEFAULT_MAX_ATTEMPTS;
|
||||
import static com.sparrowwallet.sparrow.net.BatchedElectrumServerRpc.RETRY_DELAY_SECS;
|
||||
|
||||
public class PagedBatchRequestBuilder<K, V> extends AbstractBuilder {
|
||||
public static final int DEFAULT_PAGE_SIZE = 100;
|
||||
|
||||
private final AtomicLong counter;
|
||||
|
||||
@NotNull
|
||||
private final List<Request<K>> 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, AtomicLong counter) {
|
||||
this(transport, mapper, new ArrayList<Request<K>>(), null, null, counter);
|
||||
}
|
||||
|
||||
public PagedBatchRequestBuilder(@NotNull Transport transport, @NotNull ObjectMapper mapper,
|
||||
@NotNull List<Request<K>> requests,
|
||||
@Nullable Class<K> keysType, @Nullable Class<V> returnType,
|
||||
@Nullable AtomicLong counter) {
|
||||
super(transport, mapper);
|
||||
this.requests = requests;
|
||||
this.keysType = keysType;
|
||||
this.returnType = returnType;
|
||||
this.counter = counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new request without specifying a return type
|
||||
*
|
||||
* @param id request id as a text value
|
||||
* @param method request method
|
||||
* @param params request params
|
||||
* @return the current builder
|
||||
*/
|
||||
@NotNull
|
||||
public PagedBatchRequestBuilder<K, V> add(K id, @NotNull String method, @NotNull Object... params) {
|
||||
requests.add(new Request<K>(id, counter == null ? null : counter.incrementAndGet(), method, params));
|
||||
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, new ArrayList<Request<NK>>(), keysClass, returnType, counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, counter);
|
||||
}
|
||||
|
||||
public Map<K, V> execute() throws Exception {
|
||||
return execute(DEFAULT_MAX_ATTEMPTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates, executes the request and process response
|
||||
*
|
||||
* @param maxAttempts number of times to try the request
|
||||
* @return map of responses by request ids
|
||||
*/
|
||||
@NotNull
|
||||
public Map<K, V> execute(int maxAttempts) throws Exception {
|
||||
Map<K, V> allResults = new HashMap<>();
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
|
||||
List<List<Request<K>>> pages = Lists.partition(requests, getPageSize());
|
||||
for(List<Request<K>> page : pages) {
|
||||
if(counter != null) {
|
||||
Map<Long, K> counterIdMap = new HashMap<>();
|
||||
BatchRequestBuilder<Long, V> batchRequest = client.createBatchRequest().keysType(Long.class).returnType(returnType);
|
||||
for(Request<K> request : page) {
|
||||
counterIdMap.put(request.counterId, request.id);
|
||||
batchRequest.add(request.counterId, request.method, request.params);
|
||||
}
|
||||
|
||||
try {
|
||||
Map<Long, V> pageResult = new RetryLogic<Map<Long, V>>(maxAttempts, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
|
||||
for(Map.Entry<Long, V> pageEntry : pageResult.entrySet()) {
|
||||
allResults.put(counterIdMap.get(pageEntry.getKey()), pageEntry.getValue());
|
||||
}
|
||||
} catch(JsonRpcBatchException e) {
|
||||
Map<Object, Object> mappedSuccesess = new HashMap<>();
|
||||
for(Map.Entry<?, ?> successEntry : e.getSuccesses().entrySet()) {
|
||||
mappedSuccesess.put(counterIdMap.get((Long)successEntry.getKey()), successEntry.getValue());
|
||||
}
|
||||
Map<Object, ErrorMessage> mappedErrors = new HashMap<>();
|
||||
for(Map.Entry<?, ErrorMessage> errorEntry : e.getErrors().entrySet()) {
|
||||
mappedErrors.put(counterIdMap.get((Long)errorEntry.getKey()), errorEntry.getValue());
|
||||
}
|
||||
throw new JsonRpcBatchException(e.getMessage(), mappedSuccesess, mappedErrors);
|
||||
}
|
||||
} else {
|
||||
BatchRequestBuilder<K, V> batchRequest = client.createBatchRequest().keysType(keysType).returnType(returnType);
|
||||
for(Request<K> request : page) {
|
||||
if(request.id instanceof String strReq) {
|
||||
batchRequest.add(strReq, request.method, request.params);
|
||||
} else if(request.id instanceof Integer intReq) {
|
||||
batchRequest.add(intReq, request.method, request.params);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Id of class " + request.id.getClass().getName() + " not supported");
|
||||
}
|
||||
}
|
||||
|
||||
Map<K, V> pageResult = new RetryLogic<Map<K, V>>(maxAttempts, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
|
||||
allResults.putAll(pageResult);
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
private int getPageSize() {
|
||||
int pageSize = Config.get().getMaxPageSize();
|
||||
if(pageSize < 1) {
|
||||
pageSize = DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
//Halve the page size if there have been timeouts
|
||||
if(transport instanceof TimeoutCounter timeoutCounter) {
|
||||
int timeouts = timeoutCounter.getTimeoutCount();
|
||||
if(timeouts > 0) {
|
||||
return pageSize / 2;
|
||||
}
|
||||
}
|
||||
|
||||
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(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a builder of a JSON-RPC batch request in initial state with a counter for request ids
|
||||
*
|
||||
* @return batch request builder
|
||||
*/
|
||||
@NotNull
|
||||
public static PagedBatchRequestBuilder<?, ?> create(Transport transport, AtomicLong counter) {
|
||||
return new PagedBatchRequestBuilder<Object, Object>(transport, new ObjectMapper(), counter);
|
||||
}
|
||||
|
||||
private static record Request<K>(K id, Long counterId, String method, Object[] params) {}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -297,4 +297,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
throw new ElectrumServerRpcException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getIdCounterValue() {
|
||||
return idCounter.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,18 @@ 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;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
public class TcpTransport implements Transport, Closeable {
|
||||
public class TcpTransport implements Transport, Closeable, TimeoutCounter {
|
||||
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
|
||||
@@ -243,6 +251,11 @@ public class TcpTransport implements Transport, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTimeoutCount() {
|
||||
return readTimeoutIndex;
|
||||
}
|
||||
|
||||
private static class Rpc {
|
||||
public String id;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
public interface TimeoutCounter {
|
||||
int getTimeoutCount();
|
||||
}
|
||||
+56
-7
@@ -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());
|
||||
@@ -256,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,8 +359,11 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
});
|
||||
|
||||
PublicElectrumServer configPublicElectrumServer = PublicElectrumServer.fromUrl(config.getPublicElectrumServer());
|
||||
if(configPublicElectrumServer == null) {
|
||||
publicElectrumServer.setValue(PublicElectrumServer.values()[new Random().nextInt(PublicElectrumServer.values().length)]);
|
||||
if(configPublicElectrumServer == null && PublicElectrumServer.supportedNetwork()) {
|
||||
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
|
||||
if(!servers.isEmpty()) {
|
||||
publicElectrumServer.setValue(servers.get(new Random().nextInt(servers.size())));
|
||||
}
|
||||
} else {
|
||||
publicElectrumServer.setValue(configPublicElectrumServer);
|
||||
}
|
||||
@@ -805,4 +842,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,419 @@
|
||||
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.getHdWallet() != null) {
|
||||
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);
|
||||
});
|
||||
}, 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,672 @@
|
||||
package com.sparrowwallet.sparrow.soroban;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
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.protocol.TransactionInput;
|
||||
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.event.WalletNodeHistoryChangedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
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.Duration;
|
||||
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 VBox step4;
|
||||
|
||||
@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;
|
||||
|
||||
@FXML
|
||||
private Label step4Desc;
|
||||
|
||||
@FXML
|
||||
private ProgressBar broadcastProgressBar;
|
||||
|
||||
@FXML
|
||||
private Label broadcastProgressLabel;
|
||||
|
||||
@FXML
|
||||
private Glyph broadcastSuccessful;
|
||||
|
||||
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;
|
||||
|
||||
private ElectrumServer.TransactionMempoolService transactionMempoolService;
|
||||
|
||||
private boolean closed;
|
||||
|
||||
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());
|
||||
step4.managedProperty().bind(step4.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());
|
||||
broadcastProgressBar.managedProperty().bind(broadcastProgressBar.visibleProperty());
|
||||
broadcastProgressLabel.managedProperty().bind(broadcastProgressLabel.visibleProperty());
|
||||
broadcastSuccessful.managedProperty().bind(broadcastSuccessful.visibleProperty());
|
||||
broadcastSuccessful.setVisible(false);
|
||||
|
||||
step2.setVisible(false);
|
||||
step3.setVisible(false);
|
||||
step4.setVisible(false);
|
||||
|
||||
transactionAccepted.addListener((observable, oldValue, accepted) -> {
|
||||
if(transactionProperty.get() != null && stepProperty.get() != Step.REBROADCAST) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stepProperty.addListener((observable, oldValue, step) -> {
|
||||
if(step == Step.BROADCAST) {
|
||||
step4Desc.setText("Broadcasting the mix transaction...");
|
||||
broadcastProgressLabel.setVisible(true);
|
||||
} else if(step == Step.REBROADCAST) {
|
||||
step4Desc.setText("Rebroadcast the mix transaction.");
|
||||
broadcastProgressLabel.setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
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 && stepProperty.get() != Step.REBROADCAST) {
|
||||
step3Desc.setText("Transaction declined due to timeout.");
|
||||
transactionAccepted.set(Boolean.FALSE);
|
||||
}
|
||||
});
|
||||
} else if(cahoots.getStep() == 4) {
|
||||
next();
|
||||
broadcastTransaction();
|
||||
}
|
||||
}
|
||||
} 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 broadcastTransaction() {
|
||||
stepProperty.set(Step.BROADCAST);
|
||||
|
||||
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(getTransaction());
|
||||
broadcastTransactionService.setOnRunning(workerStateEvent -> {
|
||||
broadcastProgressBar.setProgress(-1);
|
||||
});
|
||||
broadcastTransactionService.setOnSucceeded(workerStateEvent -> {
|
||||
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = new HashMap<>();
|
||||
Map<BlockTransactionHashIndex, WalletNode> walletTxos = wallet.getWalletTxos();
|
||||
for(TransactionInput txInput : getTransaction().getInputs()) {
|
||||
Optional<BlockTransactionHashIndex> optSelectedUtxo = walletTxos.keySet().stream().filter(txo -> txInput.getOutpoint().getHash().equals(txo.getHash()) && txInput.getOutpoint().getIndex() == txo.getIndex())
|
||||
.findFirst();
|
||||
optSelectedUtxo.ifPresent(blockTransactionHashIndex -> selectedUtxos.put(blockTransactionHashIndex, walletTxos.get(blockTransactionHashIndex)));
|
||||
}
|
||||
|
||||
if(transactionMempoolService != null) {
|
||||
transactionMempoolService.cancel();
|
||||
}
|
||||
|
||||
transactionMempoolService = new ElectrumServer.TransactionMempoolService(wallet, getTransaction().getTxId(), new HashSet<>(selectedUtxos.values()));
|
||||
transactionMempoolService.setDelay(Duration.seconds(3));
|
||||
transactionMempoolService.setPeriod(Duration.seconds(10));
|
||||
transactionMempoolService.setRestartOnFailure(false);
|
||||
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
|
||||
Set<String> scriptHashes = transactionMempoolService.getValue();
|
||||
if(!scriptHashes.isEmpty()) {
|
||||
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHashes.iterator().next())));
|
||||
}
|
||||
|
||||
if(transactionMempoolService.getIterationCount() > 3 && transactionMempoolService.isRunning()) {
|
||||
transactionMempoolService.cancel();
|
||||
broadcastProgressBar.setProgress(0);
|
||||
log.error("Timeout searching for broadcasted transaction");
|
||||
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again.");
|
||||
stepProperty.set(Step.REBROADCAST);
|
||||
}
|
||||
});
|
||||
transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> {
|
||||
transactionMempoolService.cancel();
|
||||
broadcastProgressBar.setProgress(0);
|
||||
log.error("Timeout searching for broadcasted transaction");
|
||||
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not indicate it had entered the mempool. It is safe to try broadcasting again.");
|
||||
stepProperty.set(Step.REBROADCAST);
|
||||
});
|
||||
|
||||
if(!closed) {
|
||||
transactionMempoolService.start();
|
||||
}
|
||||
});
|
||||
broadcastTransactionService.setOnFailed(workerStateEvent -> {
|
||||
broadcastProgressBar.setProgress(0);
|
||||
Throwable exception = workerStateEvent.getSource().getException();
|
||||
while(exception.getCause() != null) {
|
||||
exception = exception.getCause();
|
||||
}
|
||||
|
||||
log.error("Error broadcasting transaction", exception);
|
||||
AppServices.showErrorDialog("Error broadcasting transaction", exception.getMessage());
|
||||
stepProperty.set(Step.REBROADCAST);
|
||||
});
|
||||
broadcastTransactionService.start();
|
||||
}
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
if(step3.isVisible()) {
|
||||
step3.setVisible(false);
|
||||
step4.setVisible(true);
|
||||
stepProperty.set(Step.BROADCAST);
|
||||
}
|
||||
}
|
||||
|
||||
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 void close() {
|
||||
closed = true;
|
||||
if(transactionMempoolService != null) {
|
||||
transactionMempoolService.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTransactionAccepted() {
|
||||
return transactionAccepted.get() == Boolean.TRUE;
|
||||
}
|
||||
|
||||
public ObjectProperty<Boolean> transactionAcceptedProperty() {
|
||||
return transactionAccepted;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
|
||||
if(event.getWalletNode(wallet) != null) {
|
||||
if(transactionMempoolService != null) {
|
||||
transactionMempoolService.cancel();
|
||||
}
|
||||
|
||||
broadcastProgressBar.setVisible(false);
|
||||
broadcastProgressLabel.setVisible(false);
|
||||
step4Desc.setText("Transaction broadcasted.");
|
||||
broadcastSuccessful.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
public enum Step {
|
||||
SETUP, COMMUNICATE, REVIEW, BROADCAST, REBROADCAST
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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);
|
||||
|
||||
EventManager.get().register(initiatorController);
|
||||
|
||||
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);
|
||||
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.APPLY);
|
||||
dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, broadcastButtonType, doneButtonType);
|
||||
|
||||
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
|
||||
Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType);
|
||||
Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType);
|
||||
Button doneButton = (Button)dialogPane.lookupButton(doneButtonType);
|
||||
nextButton.setDisable(initiatorController.counterpartyPaymentCodeProperty().get() == null);
|
||||
broadcastButton.setDisable(true);
|
||||
|
||||
nextButton.managedProperty().bind(nextButton.visibleProperty());
|
||||
cancelButton.managedProperty().bind(cancelButton.visibleProperty());
|
||||
broadcastButton.managedProperty().bind(broadcastButton.visibleProperty());
|
||||
doneButton.managedProperty().bind(doneButton.visibleProperty());
|
||||
|
||||
broadcastButton.setVisible(false);
|
||||
doneButton.setVisible(false);
|
||||
|
||||
initiatorController.counterpartyPaymentCodeProperty().addListener((observable, oldValue, paymentCode) -> {
|
||||
nextButton.setDisable(paymentCode == null || !AppServices.isConnected());
|
||||
});
|
||||
|
||||
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.setVisible(true);
|
||||
broadcastButton.setDefaultButton(true);
|
||||
broadcastButton.setDisable(false);
|
||||
} else if(step == InitiatorController.Step.BROADCAST) {
|
||||
cancelButton.setVisible(false);
|
||||
broadcastButton.setVisible(false);
|
||||
doneButton.setVisible(true);
|
||||
doneButton.setDefaultButton(true);
|
||||
} else if(step == InitiatorController.Step.REBROADCAST) {
|
||||
cancelButton.setVisible(true);
|
||||
broadcastButton.setVisible(true);
|
||||
broadcastButton.setDisable(false);
|
||||
doneButton.setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
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 -> {
|
||||
if(initiatorController.isTransactionAccepted()) {
|
||||
initiatorController.broadcastTransaction();
|
||||
} else {
|
||||
acceptAndBroadcast(initiatorController, walletId, wallet);
|
||||
}
|
||||
event.consume();
|
||||
});
|
||||
|
||||
setOnCloseRequest(event -> {
|
||||
initiatorController.close();
|
||||
EventManager.get().unregister(initiatorController);
|
||||
});
|
||||
|
||||
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,209 @@
|
||||
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;
|
||||
bip47Wallet = soroban.bip47Wallet;
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user