mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-31 03:56:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6460cb88a5 | ||
|
|
90ac786707 | ||
|
|
c86006335b | ||
|
|
a444e2af2f | ||
|
|
bcd2c966a5 | ||
|
|
f826c2d423 | ||
|
|
37bb1c5d97 | ||
|
|
68da8e6027 | ||
|
|
87fe0accd5 | ||
|
|
8d413e839c | ||
|
|
1392199f5c | ||
|
|
dc33b64a9c | ||
|
|
1b220c72ea | ||
|
|
bcf6f77340 | ||
|
|
0aac8bbea7 | ||
|
|
e1acaa8a78 | ||
|
|
13a486597c | ||
|
|
389ce2180d | ||
|
|
a7a3ad012c | ||
|
|
9f5eb41d90 | ||
|
|
3a4d385560 | ||
|
|
d3881f76b9 | ||
|
|
be5a48ce71 | ||
|
|
c8cd2d1cd9 | ||
|
|
5789e1c215 | ||
|
|
f48fb610e8 | ||
|
|
3aeba79268 | ||
|
|
4a030e9b6e | ||
|
|
2cf653702c | ||
|
|
ac438ec023 | ||
|
|
ee9247c066 | ||
|
|
4ea5ec6c0a | ||
|
|
63a9a798f3 | ||
|
|
194f0f5c1b | ||
|
|
bda5c1a709 | ||
|
|
32327928b5 | ||
|
|
b448927a6e | ||
|
|
ff578c938e | ||
|
|
998100505d | ||
|
|
a893b37bb0 | ||
|
|
4c58ebf138 | ||
|
|
76e148a107 | ||
|
|
20f5e4d8db | ||
|
|
5cd15f21ad | ||
|
|
b50cf77f3b | ||
|
|
5e8148e5b9 | ||
|
|
fa5ac7917b | ||
|
|
eda712e269 | ||
|
|
3c6adafc15 | ||
|
|
639e145672 | ||
|
|
eb8d66bf25 | ||
|
|
3a885b3a28 | ||
|
|
7e93b6b198 | ||
|
|
f949bcc1cf | ||
|
|
9e5937d2fe | ||
|
|
82a8f08440 | ||
|
|
c806959199 | ||
|
|
f7fb62fcea | ||
|
|
ea2783e51b | ||
|
|
ff7a35e68b | ||
|
|
c68cda5b2f | ||
|
|
2cb667a671 | ||
|
|
eb09076604 | ||
|
|
32bc9f6a99 | ||
|
|
dc8a8dd857 | ||
|
|
d5a26fdf5d | ||
|
|
e48e40da0d | ||
|
|
e878c4ea78 | ||
|
|
ead8fa2207 | ||
|
|
9a395f5dd1 | ||
|
|
a99114cc6f | ||
|
|
ea36c63ebb | ||
|
|
77d3c848f9 | ||
|
|
f22312e04f | ||
|
|
b9db4421df | ||
|
|
b7aa73e99f | ||
|
|
4bad46c9e3 | ||
|
|
084a3c76af | ||
|
|
2e0ca1b4fa | ||
|
|
4bc446724d | ||
|
|
0c56f5a9a1 | ||
|
|
38b27bb091 | ||
|
|
b8c3bf1bea | ||
|
|
e65f1ef3cc | ||
|
|
f25d2618b6 | ||
|
|
0adf94bf62 | ||
|
|
7d7c5e5064 |
@@ -1,19 +0,0 @@
|
||||
BSD-2-Clause Plus Patent License
|
||||
|
||||
SPDX-License-Identifier: [BSD-2-Clause-Patent](https://spdx.org/licenses/BSD-2-Clause-Patent.html)
|
||||
|
||||
Copyright © 2020 Blockchain Commons, LLC
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
Subject to the terms and conditions of this license, each copyright holder and contributor hereby grants to those receiving rights under this license a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except for failure to satisfy the conditions of this license) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer this software, where such license applies only to those patent claims, already acquired or hereafter acquired, licensable by such copyright holder or contributor that are necessarily infringed by:
|
||||
|
||||
(a) their Contribution(s) (the licensed copyrights of copyright holders and non-copyrightable additions of contributors, in source or binary form) alone; or
|
||||
(b) combination of their Contribution(s) with the work of authorship to which such Contribution(s) was added by such copyright holder or contributor, if, at the time the Contribution is added, such addition causes such combination to be necessarily infringed. The patent license shall not apply to any other combinations which include the Contribution.
|
||||
Except as expressly stated above, no rights or licenses from any copyright holder or contributor is granted under this license, whether expressly, by implication, estoppel or otherwise.
|
||||
|
||||
DISCLAIMER
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -8,23 +8,55 @@ More information (and release binaries) can be found at https://sparrowwallet.co
|
||||
|
||||
## Building
|
||||
|
||||
To clone this project, use `git clone --recursive git@github.com:sparrowwallet/sparrow.git`
|
||||
To clone this project, use
|
||||
|
||||
`git clone --recursive git@github.com:sparrowwallet/sparrow.git`
|
||||
|
||||
In order to build, Sparrow requires Java 14 to be installed. The release packages can be built using
|
||||
|
||||
`./gradlew jpackage`
|
||||
|
||||
When updating to the latest HEAD
|
||||
|
||||
`git pull --recurse-submodules`
|
||||
|
||||
All jar files created are reproducible builds.
|
||||
|
||||
## Running
|
||||
|
||||
If you prefer to run Sparrow directly from source, it can be launched with
|
||||
|
||||
`./gradlew run`
|
||||
`./sparrow`
|
||||
|
||||
Java 14 must be installed.
|
||||
|
||||
## Configuration
|
||||
|
||||
Sparrow stores it's configuration, log file and wallets in a location appropriate to the operating system:
|
||||
Sparrow has a number of command line options, for example to change it's home folder or use testnet:
|
||||
|
||||
```
|
||||
./sparrow -h
|
||||
|
||||
Usage: sparrow [options]
|
||||
Options:
|
||||
--dir, -d
|
||||
Path to Sparrow home folder
|
||||
--help, -h
|
||||
Show usage
|
||||
--network, -n
|
||||
Network to use
|
||||
Possible Values: [mainnet, testnet, regtest]
|
||||
```
|
||||
|
||||
As a fallback, the network (mainnet, testnet or regtest) can also be set using an environment variable `SPARROW_NETWORK`. For example:
|
||||
|
||||
`export SPARROW_NETWORK=testnet`
|
||||
|
||||
A final fallback which can be useful when running the Sparrow binary is to create a file called ``network-testnet`` in the Sparrow home folder (see below) to configure the testnet network.
|
||||
|
||||
Note that if you are connecting to an Electrum server when using testnet, that server will need to running on testnet configuration as well.
|
||||
|
||||
When not explicitly configured using the command line argument above, Sparrow stores it's mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
|
||||
|
||||
Platform | Location
|
||||
-------- | --------
|
||||
@@ -32,6 +64,8 @@ OSX | ~/.sparrow
|
||||
Linux | ~/.sparrow
|
||||
Windows | %APPDATA%/Sparrow
|
||||
|
||||
Testnet and regtest configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Please use the [Issues](https://github.com/sparrowwallet/sparrow/issues) tab above to report an issue. If possible, look in the sparrow.log file in the configuration directory for information helpful in debugging.
|
||||
|
||||
+19
-11
@@ -1,15 +1,15 @@
|
||||
plugins {
|
||||
id 'application'
|
||||
id 'org.openjfx.javafxplugin' version '0.0.8'
|
||||
id 'org.openjfx.javafxplugin' version '0.0.9'
|
||||
id 'org.kordamp.gradle.jdeps' version '0.9.0'
|
||||
id 'org.beryx.jlink' version '2.17.4'
|
||||
id 'org.beryx.jlink' version '2.22.0'
|
||||
}
|
||||
|
||||
def sparrowVersion = '0.9.3'
|
||||
def sparrowVersion = '0.9.7'
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
def osName = os.getFamilyName()
|
||||
if(os.macOsX) {
|
||||
osName = "mac"
|
||||
osName = "osx"
|
||||
}
|
||||
|
||||
group "com.sparrowwallet"
|
||||
@@ -19,6 +19,8 @@ repositories {
|
||||
mavenCentral()
|
||||
maven { url 'https://oss.sonatype.org/content/groups/public' }
|
||||
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url 'http://maven.openimaj.org/' }
|
||||
}
|
||||
|
||||
tasks.withType(AbstractArchiveTask) {
|
||||
@@ -27,7 +29,7 @@ tasks.withType(AbstractArchiveTask) {
|
||||
}
|
||||
|
||||
javafx {
|
||||
version = "14"
|
||||
version = "15"
|
||||
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
|
||||
}
|
||||
|
||||
@@ -49,11 +51,14 @@ dependencies {
|
||||
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
|
||||
exclude group: 'org.slf4j'
|
||||
}
|
||||
implementation('co.nstant.in:cbor:0.9')
|
||||
implementation('com.nativelibs4java:bridj:0.7-20200803')
|
||||
implementation('com.sparrowwallet:hummingbird:1.4')
|
||||
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
|
||||
exclude group: 'com.google.android.tools', module: 'dx'
|
||||
}
|
||||
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
|
||||
exclude group: 'com.nativelibs4java', module: 'bridj'
|
||||
}
|
||||
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8")
|
||||
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
|
||||
implementation('org.controlsfx:controlsfx:11.0.2' ) {
|
||||
exclude group: 'org.openjfx', module: 'javafx-base'
|
||||
@@ -80,8 +85,8 @@ compileJava {
|
||||
|
||||
processResources {
|
||||
doLast {
|
||||
delete fileTree("$buildDir/resources/main/external").matching {
|
||||
exclude "$osName/**"
|
||||
delete fileTree("$buildDir/resources/main/native").matching {
|
||||
exclude "${osName}/**"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +106,8 @@ run {
|
||||
"--add-opens=javafx.controls/com.sun.javafx.scene.control=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.menu=centerdevice.nsmenufx",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow"]
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow"]
|
||||
|
||||
if(os.macOsX) {
|
||||
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow.png",
|
||||
@@ -118,6 +124,7 @@ jlink {
|
||||
requires 'javafx.base'
|
||||
requires 'com.fasterxml.jackson.databind'
|
||||
requires 'jdk.crypto.cryptoki'
|
||||
requires 'java.management'
|
||||
}
|
||||
|
||||
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png']
|
||||
@@ -139,6 +146,7 @@ jlink {
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
|
||||
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module",
|
||||
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
|
||||
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
|
||||
"--add-reads=com.sparrowwallet.merged.module=java.desktop"]
|
||||
}
|
||||
addExtraDependencies("javafx")
|
||||
@@ -160,7 +168,7 @@ jlink {
|
||||
}
|
||||
if(os.macOsX) {
|
||||
installerOptions += ['--mac-sign', '--mac-signing-key-user-name', 'Craig Raw (UPLVMSK9D7)']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/mac/sparrow.icns', '--resource-dir', 'src/main/deploy/package/mac/']
|
||||
imageOptions += ['--icon', 'src/main/deploy/package/osx/sparrow.icns', '--resource-dir', 'src/main/deploy/package/osx/']
|
||||
installerType = "dmg"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule drongo updated: 10ebfe463d...49799fc0c8
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
args="$*"
|
||||
args="${args%"${args##*[![:space:]]}"}"
|
||||
|
||||
if [ -n "$args" ]
|
||||
then
|
||||
./gradlew run --args="$args"
|
||||
else
|
||||
./gradlew run
|
||||
fi
|
||||
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
set ARGS=%*
|
||||
|
||||
if "%ARGS%" == "" (
|
||||
gradlew.bat run
|
||||
) else (
|
||||
gradlew.bat run --args="%ARGS%"
|
||||
)
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.9.3</string>
|
||||
<string>0.9.7</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
@@ -4,8 +4,12 @@ import com.google.common.base.Charsets;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.google.common.io.ByteSource;
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
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.policy.PolicyType;
|
||||
@@ -14,11 +18,13 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import com.sparrowwallet.sparrow.net.VersionCheckService;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
|
||||
import com.sparrowwallet.sparrow.transaction.TransactionController;
|
||||
import com.sparrowwallet.sparrow.transaction.TransactionData;
|
||||
@@ -68,10 +74,10 @@ import java.util.stream.Collectors;
|
||||
public class AppController implements Initializable {
|
||||
private static final Logger log = LoggerFactory.getLogger(AppController.class);
|
||||
|
||||
private static final int SERVER_PING_PERIOD = 10 * 1000;
|
||||
private static final int SERVER_PING_PERIOD = 8 * 60 * 1000;
|
||||
private static final int ENUMERATE_HW_PERIOD = 30 * 1000;
|
||||
|
||||
private static final int RATES_PERIOD = 5 * 60 * 1000;
|
||||
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
|
||||
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
|
||||
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
|
||||
|
||||
@@ -97,6 +103,9 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private ToggleGroup bitcoinUnit;
|
||||
|
||||
@FXML
|
||||
private ToggleGroup theme;
|
||||
|
||||
@FXML
|
||||
private CheckMenuItem showTxHex;
|
||||
|
||||
@@ -125,16 +134,22 @@ public class AppController implements Initializable {
|
||||
|
||||
private Hwi.ScheduledEnumerateService deviceEnumerateService;
|
||||
|
||||
private VersionCheckService versionCheckService;
|
||||
|
||||
private static Integer currentBlockHeight;
|
||||
|
||||
public static boolean showTxHexProperty;
|
||||
|
||||
private static Map<Integer, Double> targetBlockFeeRates;
|
||||
|
||||
private static Double minimumRelayFeeRate;
|
||||
|
||||
private static CurrencyRate fiatCurrencyExchangeRate;
|
||||
|
||||
private static List<Device> devices;
|
||||
|
||||
private static Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
@@ -221,8 +236,18 @@ public class AppController implements Initializable {
|
||||
Config.get().setBitcoinUnit(unit);
|
||||
}
|
||||
final BitcoinUnit selectedUnit = unit;
|
||||
Optional<Toggle> selectedToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
|
||||
selectedToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
|
||||
Optional<Toggle> selectedUnitToggle = bitcoinUnit.getToggles().stream().filter(toggle -> selectedUnit.equals(toggle.getUserData())).findFirst();
|
||||
selectedUnitToggle.ifPresent(toggle -> bitcoinUnit.selectToggle(toggle));
|
||||
|
||||
Theme configTheme = Config.get().getTheme();
|
||||
if(configTheme == null) {
|
||||
configTheme = Theme.LIGHT;
|
||||
Config.get().setTheme(Theme.LIGHT);
|
||||
}
|
||||
final Theme selectedTheme = configTheme;
|
||||
Optional<Toggle> selectedThemeToggle = theme.getToggles().stream().filter(toggle -> selectedTheme.equals(toggle.getUserData())).findFirst();
|
||||
selectedThemeToggle.ifPresent(toggle -> theme.selectToggle(toggle));
|
||||
setTheme(null);
|
||||
|
||||
showTxHex.setSelected(true);
|
||||
showTxHexProperty = true;
|
||||
@@ -247,9 +272,18 @@ public class AppController implements Initializable {
|
||||
if(!ratesService.isRunning() && ratesService.getExchangeSource() != ExchangeSource.NONE) {
|
||||
ratesService.start();
|
||||
}
|
||||
|
||||
if(versionCheckService.getState() == Worker.State.CANCELLED) {
|
||||
versionCheckService.reset();
|
||||
}
|
||||
|
||||
if(!versionCheckService.isRunning() && Config.get().isCheckNewVersions()) {
|
||||
versionCheckService.start();
|
||||
}
|
||||
} else {
|
||||
connectionService.cancel();
|
||||
ratesService.cancel();
|
||||
versionCheckService.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -268,10 +302,15 @@ public class AppController implements Initializable {
|
||||
ExchangeSource source = config.getExchangeSource() != null ? config.getExchangeSource() : DEFAULT_EXCHANGE_SOURCE;
|
||||
Currency currency = config.getFiatCurrency() != null ? config.getFiatCurrency() : DEFAULT_FIAT_CURRENCY;
|
||||
ratesService = createRatesService(source, currency);
|
||||
if (config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) {
|
||||
if(config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) {
|
||||
ratesService.start();
|
||||
}
|
||||
|
||||
versionCheckService = createVersionCheckService();
|
||||
if(config.getMode() == Mode.ONLINE && config.isCheckNewVersions()) {
|
||||
versionCheckService.start();
|
||||
}
|
||||
|
||||
openTransactionIdItem.disableProperty().bind(onlineProperty.not());
|
||||
}
|
||||
|
||||
@@ -280,6 +319,13 @@ public class AppController implements Initializable {
|
||||
connectionService.setPeriod(new Duration(SERVER_PING_PERIOD));
|
||||
connectionService.setRestartOnFailure(true);
|
||||
|
||||
EventManager.get().register(connectionService);
|
||||
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(connectionService.isRunning()) {
|
||||
EventManager.get().post(new StatusEvent(newValue));
|
||||
}
|
||||
});
|
||||
|
||||
connectionService.setOnSucceeded(successEvent -> {
|
||||
changeMode = false;
|
||||
onlineProperty.setValue(true);
|
||||
@@ -316,6 +362,22 @@ public class AppController implements Initializable {
|
||||
return ratesService;
|
||||
}
|
||||
|
||||
private VersionCheckService createVersionCheckService() {
|
||||
VersionCheckService versionCheckService = new VersionCheckService();
|
||||
versionCheckService.setDelay(Duration.seconds(10));
|
||||
versionCheckService.setPeriod(Duration.hours(VERSION_CHECK_PERIOD_HOURS));
|
||||
versionCheckService.setRestartOnFailure(true);
|
||||
|
||||
versionCheckService.setOnSucceeded(successEvent -> {
|
||||
VersionUpdatedEvent event = versionCheckService.getValue();
|
||||
if(event != null) {
|
||||
EventManager.get().post(event);
|
||||
}
|
||||
});
|
||||
|
||||
return versionCheckService;
|
||||
}
|
||||
|
||||
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() {
|
||||
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null);
|
||||
enumerateService.setPeriod(new Duration(ENUMERATE_HW_PERIOD));
|
||||
@@ -350,8 +412,8 @@ public class AppController implements Initializable {
|
||||
tk.createQuitMenuItem(MainApp.APP_NAME));
|
||||
tk.setApplicationMenu(defaultApplicationMenu);
|
||||
|
||||
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("macHide"));
|
||||
helpMenu.getItems().removeIf(item -> item.getStyleClass().contains("macHide"));
|
||||
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
helpMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,11 +431,11 @@ public class AppController implements Initializable {
|
||||
Stage stage = new Stage();
|
||||
stage.setTitle("About " + MainApp.APP_NAME);
|
||||
stage.initStyle(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? StageStyle.UNDECORATED : StageStyle.DECORATED);
|
||||
setStageIcon(stage);
|
||||
stage.setResizable(false);
|
||||
stage.setScene(new Scene(root));
|
||||
controller.setStage(stage);
|
||||
controller.initializeView();
|
||||
setStageIcon(stage);
|
||||
|
||||
return stage;
|
||||
} catch(IOException e) {
|
||||
@@ -389,7 +451,7 @@ public class AppController implements Initializable {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open Transaction");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*.*"),
|
||||
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("PSBT", "*.psbt"),
|
||||
new FileChooser.ExtensionFilter("TXN", "*.txn")
|
||||
);
|
||||
@@ -503,17 +565,14 @@ public class AppController implements Initializable {
|
||||
if(result.transaction != null) {
|
||||
Tab tab = addTransactionTab(null, result.transaction);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
}
|
||||
if(result.psbt != null) {
|
||||
} else if(result.psbt != null) {
|
||||
Tab tab = addTransactionTab(null, result.psbt);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
}
|
||||
if(result.error != null) {
|
||||
showErrorDialog("Invalid QR Code", result.error);
|
||||
}
|
||||
if(result.exception != null) {
|
||||
log.error("Error opening webcam", result.exception);
|
||||
showErrorDialog("Error opening webcam", result.exception.getMessage());
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error scanning QR", result.exception);
|
||||
showErrorDialog("Error scanning QR", result.exception.getMessage());
|
||||
} else {
|
||||
AppController.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a transaction or PSBT");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,6 +649,10 @@ public class AppController implements Initializable {
|
||||
return targetBlockFeeRates;
|
||||
}
|
||||
|
||||
public static Double getMinimumRelayFeeRate() {
|
||||
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
|
||||
}
|
||||
|
||||
public static CurrencyRate getFiatCurrencyExchangeRate() {
|
||||
return fiatCurrencyExchangeRate;
|
||||
}
|
||||
@@ -598,6 +661,17 @@ public class AppController implements Initializable {
|
||||
return devices == null ? new ArrayList<>() : devices;
|
||||
}
|
||||
|
||||
public static BitcoinURI getPayjoinURI(Address address) {
|
||||
return payjoinURIs.get(address);
|
||||
}
|
||||
|
||||
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
|
||||
if(bitcoinURI.getPayjoinUrl() == null) {
|
||||
throw new IllegalArgumentException("Not a payjoin URI");
|
||||
}
|
||||
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
|
||||
}
|
||||
|
||||
public Map<Wallet, Storage> getOpenWallets() {
|
||||
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
|
||||
|
||||
@@ -615,6 +689,7 @@ public class AppController implements Initializable {
|
||||
public static void showErrorDialog(String title, String content) {
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
setStageIcon(alert.getDialogPane().getScene().getWindow());
|
||||
alert.getDialogPane().getScene().getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
|
||||
alert.setTitle(title);
|
||||
alert.setHeaderText(title);
|
||||
alert.setContentText(content);
|
||||
@@ -624,6 +699,10 @@ public class AppController implements Initializable {
|
||||
public static void setStageIcon(Window window) {
|
||||
Stage stage = (Stage)window;
|
||||
stage.getIcons().add(new Image(AppController.class.getResourceAsStream("/image/sparrow.png")));
|
||||
|
||||
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
|
||||
stage.getScene().getStylesheets().add(AppController.class.getResource("darktheme.css").toExternalForm());
|
||||
}
|
||||
}
|
||||
|
||||
public static Font getMonospaceFont() {
|
||||
@@ -706,7 +785,11 @@ public class AppController implements Initializable {
|
||||
FileType fileType = IOUtils.getFileType(file);
|
||||
if(FileType.JSON.equals(fileType)) {
|
||||
Wallet wallet = storage.loadWallet();
|
||||
checkWalletNetwork(wallet);
|
||||
restorePublicKeysFromSeed(wallet, null);
|
||||
if(!wallet.isValid()) {
|
||||
throw new IllegalStateException("Wallet file is not valid.");
|
||||
}
|
||||
Tab tab = addWalletTab(storage, wallet);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
} else if(FileType.BINARY.equals(fileType)) {
|
||||
@@ -722,10 +805,11 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
|
||||
Storage.WalletAndKey walletAndKey = loadWalletService.getValue();
|
||||
try {
|
||||
checkWalletNetwork(walletAndKey.wallet);
|
||||
restorePublicKeysFromSeed(walletAndKey.wallet, walletAndKey.key);
|
||||
Tab tab = addWalletTab(storage, walletAndKey.wallet);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
} catch(MnemonicException e) {
|
||||
} catch(Exception e) {
|
||||
showErrorDialog("Error Opening Wallet", e.getMessage());
|
||||
} finally {
|
||||
walletAndKey.key.clear();
|
||||
@@ -737,8 +821,11 @@ public class AppController implements Initializable {
|
||||
if(exception instanceof InvalidPasswordException) {
|
||||
showErrorDialog("Invalid Password", "The wallet password was invalid.");
|
||||
} else {
|
||||
log.error("Error Opening Wallet", exception);
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage());
|
||||
if(!attemptImportWallet(file, password)) {
|
||||
log.error("Error Opening Wallet", exception);
|
||||
showErrorDialog("Error Opening Wallet", exception.getMessage() == null ? "Unsupported file format" : exception.getMessage());
|
||||
}
|
||||
password.clear();
|
||||
}
|
||||
});
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
|
||||
@@ -747,8 +834,16 @@ public class AppController implements Initializable {
|
||||
throw new IOException("Unsupported file type");
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.error("Error opening wallet", e);
|
||||
showErrorDialog("Error Opening Wallet", e.getMessage());
|
||||
if(!attemptImportWallet(file, null)) {
|
||||
log.error("Error opening wallet", e);
|
||||
showErrorDialog("Error Opening Wallet", e.getMessage() == null ? "Unsupported file format" : e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkWalletNetwork(Wallet wallet) {
|
||||
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
|
||||
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,34 +894,100 @@ public class AppController implements Initializable {
|
||||
Optional<Wallet> optionalWallet = dlg.showAndWait();
|
||||
if(optionalWallet.isPresent()) {
|
||||
Wallet wallet = optionalWallet.get();
|
||||
File walletFile = Storage.getWalletFile(wallet.getName());
|
||||
addImportedWallet(wallet);
|
||||
}
|
||||
}
|
||||
|
||||
if(walletFile.exists()) {
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
alert.setTitle("Existing wallet found");
|
||||
alert.setHeaderText("Replace existing wallet?");
|
||||
alert.setContentText("Wallet file " + wallet.getName() + " already exists");
|
||||
Optional<ButtonType> result = alert.showAndWait();
|
||||
if(result.isPresent() && result.get() == ButtonType.CANCEL) {
|
||||
return;
|
||||
private boolean attemptImportWallet(File file, SecureString password) {
|
||||
List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(), new Electrum(), new Specter(), new CoboVaultSinglesig(), new CoboVaultMultisig());
|
||||
for(WalletImport importer : walletImporters) {
|
||||
try(FileInputStream inputStream = new FileInputStream(file)) {
|
||||
if(importer.isEncrypted(file) && password == null) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
if(optionalPassword.isPresent()) {
|
||||
password = optionalPassword.get();
|
||||
}
|
||||
}
|
||||
|
||||
//Close existing wallet first if open
|
||||
for(Iterator<Tab> iter = tabs.getTabs().iterator(); iter.hasNext(); ) {
|
||||
Tab tab = iter.next();
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData.getType() == TabData.TabType.WALLET) {
|
||||
WalletTabData walletTabData = (WalletTabData) tabData;
|
||||
if(walletTabData.getStorage().getWalletFile().equals(walletFile)) {
|
||||
iter.remove();
|
||||
}
|
||||
Wallet wallet = importer.importWallet(inputStream, password == null ? null: password.asString());
|
||||
if(wallet.getName() == null) {
|
||||
wallet.setName(file.getName());
|
||||
}
|
||||
addImportedWallet(wallet);
|
||||
return true;
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void addImportedWallet(Wallet wallet) {
|
||||
File walletFile = Storage.getExistingWallet(wallet.getName());
|
||||
|
||||
if(walletFile != null) {
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
alert.setTitle("Existing wallet found");
|
||||
alert.setHeaderText("Replace existing wallet?");
|
||||
alert.setContentText("Wallet file " + walletFile.getName() + " already exists.\n");
|
||||
Optional<ButtonType> result = alert.showAndWait();
|
||||
if(result.isPresent() && result.get() == ButtonType.CANCEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Close existing wallet first if open
|
||||
for(Iterator<Tab> iter = tabs.getTabs().iterator(); iter.hasNext(); ) {
|
||||
Tab tab = iter.next();
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData.getType() == TabData.TabType.WALLET) {
|
||||
WalletTabData walletTabData = (WalletTabData) tabData;
|
||||
if(walletTabData.getStorage().getWalletFile().equals(walletFile)) {
|
||||
iter.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Storage storage = new Storage(walletFile);
|
||||
Tab tab = addWalletTab(storage, wallet);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
walletFile.delete();
|
||||
}
|
||||
|
||||
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
if(password.get().length() == 0) {
|
||||
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
|
||||
Tab tab = addWalletTab(storage, wallet);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
} else {
|
||||
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
|
||||
keyDerivationService.setOnSucceeded(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Done"));
|
||||
ECKey encryptionFullKey = keyDerivationService.getValue();
|
||||
Key key = null;
|
||||
|
||||
try {
|
||||
ECKey encryptionPubKey = ECKey.fromPublicOnly(encryptionFullKey);
|
||||
key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
|
||||
wallet.encrypt(key);
|
||||
storage.setEncryptionPubKey(encryptionPubKey);
|
||||
Tab tab = addWalletTab(storage, wallet);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
} finally {
|
||||
encryptionFullKey.clear();
|
||||
if(key != null) {
|
||||
key.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
keyDerivationService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Failed"));
|
||||
showErrorDialog("Error encrypting wallet", keyDerivationService.getException().getMessage());
|
||||
});
|
||||
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.START, "Encrypting wallet..."));
|
||||
keyDerivationService.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,6 +1046,9 @@ public class AppController implements Initializable {
|
||||
if(name.endsWith(".json")) {
|
||||
name = name.substring(0, name.lastIndexOf('.'));
|
||||
}
|
||||
if(!name.equals(wallet.getName())) {
|
||||
wallet.setName(name);
|
||||
}
|
||||
Tab tab = new Tab(name);
|
||||
tab.setContextMenu(getTabContextMenu(tab));
|
||||
tab.setClosable(true);
|
||||
@@ -1052,6 +1216,19 @@ public class AppController implements Initializable {
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
public void setTheme(ActionEvent event) {
|
||||
Theme selectedTheme = (Theme)theme.getSelectedToggle().getUserData();
|
||||
if(Config.get().getTheme() != selectedTheme) {
|
||||
Config.get().setTheme(selectedTheme);
|
||||
}
|
||||
|
||||
if(selectedTheme == Theme.DARK) {
|
||||
tabs.getScene().getStylesheets().add(getClass().getResource("darktheme.css").toExternalForm());
|
||||
} else {
|
||||
tabs.getScene().getStylesheets().remove(getClass().getResource("darktheme.css").toExternalForm());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void tabSelected(TabSelectedEvent event) {
|
||||
String tabName = event.getTabName();
|
||||
@@ -1155,7 +1332,7 @@ public class AppController implements Initializable {
|
||||
public void statusUpdated(StatusEvent event) {
|
||||
statusBar.setText(event.getStatus());
|
||||
|
||||
PauseTransition wait = new PauseTransition(Duration.seconds(10));
|
||||
PauseTransition wait = new PauseTransition(Duration.seconds(20));
|
||||
wait.setOnFinished((e) -> {
|
||||
if(statusBar.getText().equals(event.getStatus())) {
|
||||
statusBar.setText("");
|
||||
@@ -1164,6 +1341,20 @@ public class AppController implements Initializable {
|
||||
wait.play();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void versionUpdated(VersionUpdatedEvent event) {
|
||||
Hyperlink versionUpdateLabel = new Hyperlink("Sparrow " + event.getVersion() + " available");
|
||||
versionUpdateLabel.setOnAction(event1 -> {
|
||||
application.getHostServices().showDocument("https://www.sparrowwallet.com/download");
|
||||
});
|
||||
|
||||
if(statusBar.getRightItems().size() > 0 && statusBar.getRightItems().get(0) instanceof Hyperlink) {
|
||||
statusBar.getRightItems().remove(0);
|
||||
}
|
||||
|
||||
statusBar.getRightItems().add(0, versionUpdateLabel);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void timedWorker(TimedEvent event) {
|
||||
if(event.getTimeMills() == 0) {
|
||||
@@ -1207,7 +1398,7 @@ public class AppController implements Initializable {
|
||||
} else {
|
||||
if(usbStatus == null) {
|
||||
usbStatus = new UsbStatusButton();
|
||||
statusBar.getRightItems().add(0, usbStatus);
|
||||
statusBar.getRightItems().add(Math.max(statusBar.getRightItems().size() - 1, 0), usbStatus);
|
||||
} else {
|
||||
usbStatus.getItems().remove(0, usbStatus.getItems().size());
|
||||
}
|
||||
@@ -1220,6 +1411,7 @@ public class AppController implements Initializable {
|
||||
public void newConnection(ConnectionEvent event) {
|
||||
currentBlockHeight = event.getBlockHeight();
|
||||
targetBlockFeeRates = event.getTargetBlockFeeRates();
|
||||
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
|
||||
String banner = event.getServerBanner();
|
||||
String status = "Connected to " + Config.get().getElectrumServer() + " at height " + event.getBlockHeight();
|
||||
EventManager.get().post(new StatusEvent(status));
|
||||
@@ -1278,9 +1470,19 @@ public class AppController implements Initializable {
|
||||
fiatCurrencyExchangeRate = event.getCurrencyRate();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void versionCheckStatus(VersionCheckStatusEvent event) {
|
||||
versionCheckService.cancel();
|
||||
|
||||
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled()) {
|
||||
versionCheckService = createVersionCheckService();
|
||||
versionCheckService.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
List<File> walletFiles = event.getWalletsMap().values().stream().map(storage -> storage.getWalletFile()).collect(Collectors.toList());
|
||||
List<File> walletFiles = event.getWalletsMap().values().stream().map(Storage::getWalletFile).collect(Collectors.toList());
|
||||
Config.get().setRecentWalletFiles(walletFiles);
|
||||
|
||||
boolean usbWallet = false;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.Parameter;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
|
||||
public class Args {
|
||||
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
|
||||
public String dir;
|
||||
|
||||
@Parameter(names = { "--network", "-n" }, description = "Network to use")
|
||||
public Network network;
|
||||
|
||||
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
|
||||
public boolean help;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.sparrow.control.DescriptorArea;
|
||||
import com.sparrowwallet.sparrow.control.ScriptArea;
|
||||
import javafx.geometry.Point2D;
|
||||
import javafx.scene.control.Label;
|
||||
@@ -39,4 +42,42 @@ public abstract class BaseController {
|
||||
protected String describeScriptChunk(ScriptChunk chunk) {
|
||||
return chunk.toString();
|
||||
}
|
||||
|
||||
protected void initializeDescriptorField(DescriptorArea descriptorArea) {
|
||||
Popup popup = new Popup();
|
||||
Label popupMsg = new Label();
|
||||
popupMsg.getStyleClass().add("tooltip");
|
||||
popup.getContent().add(popupMsg);
|
||||
|
||||
descriptorArea.setMouseOverTextDelay(Duration.ofMillis(150));
|
||||
descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
|
||||
TwoDimensional.Position position = descriptorArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
|
||||
int index = descriptorArea.getWallet().getPolicyType() == PolicyType.SINGLE ? position.getMajor() - 1 : ((position.getMajor() - 1) / 2);
|
||||
if(position.getMajor() > 0 && index >= 0 && index < descriptorArea.getWallet().getKeystores().size()) {
|
||||
Keystore hoverKeystore = descriptorArea.getWallet().getKeystores().get(index);
|
||||
Point2D pos = e.getScreenPosition();
|
||||
popupMsg.setText(describeKeystore(hoverKeystore));
|
||||
popup.show(descriptorArea, pos.getX(), pos.getY() + 10);
|
||||
}
|
||||
});
|
||||
descriptorArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
|
||||
popup.hide();
|
||||
});
|
||||
}
|
||||
|
||||
protected String describeKeystore(Keystore keystore) {
|
||||
if(keystore.isValid()) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("[");
|
||||
builder.append(keystore.getKeyDerivation().getMasterFingerprint());
|
||||
builder.append("/");
|
||||
builder.append(keystore.getKeyDerivation().getDerivationPath().replaceFirst("^m?/", ""));
|
||||
builder.append("]");
|
||||
builder.append(keystore.getExtendedPublicKey().toString());
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
return "Invalid";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
import com.beust.jcommander.JCommander;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.sparrow.control.WelcomeDialog;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.FileType;
|
||||
import com.sparrowwallet.sparrow.io.IOUtils;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
|
||||
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
|
||||
import javafx.application.Application;
|
||||
@@ -17,18 +20,20 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.text.Font;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.glyphfont.GlyphFontRegistry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class MainApp extends Application {
|
||||
private static final Logger log = LoggerFactory.getLogger(MainApp.class);
|
||||
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "0.9.3";
|
||||
public static final String APP_VERSION = "0.9.7";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
private Stage mainStage;
|
||||
|
||||
@@ -107,7 +112,42 @@ public class MainApp extends Application {
|
||||
mainStage.close();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, args);
|
||||
public static void main(String[] argv) {
|
||||
Args args = new Args();
|
||||
JCommander jCommander = JCommander.newBuilder().addObject(args).programName(APP_NAME.toLowerCase()).acceptUnknownOptions(true).build();
|
||||
jCommander.parse(argv);
|
||||
if(args.help) {
|
||||
jCommander.usage();
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
if(args.dir != null) {
|
||||
log.info("Using configured Sparrow home folder of " + args.dir);
|
||||
System.setProperty(APP_HOME_PROPERTY, args.dir);
|
||||
}
|
||||
|
||||
if(args.network != null) {
|
||||
Network.set(args.network);
|
||||
} else {
|
||||
String envNetwork = System.getenv(NETWORK_ENV_PROPERTY);
|
||||
if(envNetwork != null) {
|
||||
try {
|
||||
Network.set(Network.valueOf(envNetwork.toUpperCase()));
|
||||
} catch(Exception e) {
|
||||
log.warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File testnetFlag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET.getName());
|
||||
if(testnetFlag.exists()) {
|
||||
Network.set(Network.TESTNET);
|
||||
}
|
||||
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
log.info("Using " + Network.get() + " configuration");
|
||||
}
|
||||
|
||||
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.sparrowwallet.sparrow;
|
||||
|
||||
public enum Theme {
|
||||
LIGHT, DARK
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public class AddressCell extends TreeTableCell<Entry, Entry> {
|
||||
}
|
||||
|
||||
private String getTooltipText(UtxoEntry utxoEntry) {
|
||||
return utxoEntry.getNode().getDerivationPath() + (utxoEntry.isDuplicateAddress() ? " (Duplicate address)" : "");
|
||||
return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (utxoEntry.isDuplicateAddress() ? " (Duplicate address)" : "");
|
||||
}
|
||||
|
||||
public static Glyph getDuplicateGlyph() {
|
||||
|
||||
@@ -2,8 +2,11 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
|
||||
public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
@@ -33,4 +36,20 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
|
||||
Platform.runLater(() -> {
|
||||
if(event.getErrorMessage() != null) {
|
||||
setPlaceholder(new Label("Error loading transactions: " + event.getErrorMessage()));
|
||||
} else if(event.isLoading()) {
|
||||
if(event.getStatusMessage() != null) {
|
||||
setPlaceholder(new Label(event.getStatusMessage() + "..."));
|
||||
} else {
|
||||
setPlaceholder(new Label("Loading transactions..."));
|
||||
}
|
||||
} else {
|
||||
setPlaceholder(new Label("No transactions"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import org.fxmisc.richtext.CodeArea;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.sparrowwallet.drongo.policy.PolicyType.MULTI;
|
||||
import static com.sparrowwallet.drongo.policy.PolicyType.SINGLE;
|
||||
import static com.sparrowwallet.drongo.protocol.ScriptType.MULTISIG;
|
||||
|
||||
public class DescriptorArea extends CodeArea {
|
||||
private Wallet wallet;
|
||||
|
||||
public void setWallet(Wallet wallet) {
|
||||
clear();
|
||||
this.wallet = wallet;
|
||||
|
||||
DescriptorContextMenu contextMenu = new DescriptorContextMenu(wallet, this);
|
||||
setContextMenu(contextMenu);
|
||||
|
||||
PolicyType policyType = wallet.getPolicyType();
|
||||
ScriptType scriptType = wallet.getScriptType();
|
||||
List<Keystore> keystores = wallet.getKeystores();
|
||||
int threshold = wallet.getDefaultPolicy().getNumSignaturesRequired();
|
||||
|
||||
if(SINGLE.equals(policyType)) {
|
||||
append(scriptType.getDescriptor(), "descriptor-text");
|
||||
replace(getLength(), getLength(), keystores.get(0).getScriptName(), List.of(keystores.get(0).isValid() ? "descriptor-text" : "descriptor-error", keystores.get(0).getScriptName()));
|
||||
append(scriptType.getCloseDescriptor(), "descriptor-text");
|
||||
}
|
||||
|
||||
if(MULTI.equals(policyType)) {
|
||||
append(scriptType.getDescriptor(), "descriptor-text");
|
||||
append(MULTISIG.getDescriptor(), "descriptor-text");
|
||||
append(Integer.toString(threshold), "descriptor-text");
|
||||
|
||||
for(Keystore keystore : keystores) {
|
||||
append(",", "descriptor-text");
|
||||
replace(getLength(), getLength(), keystore.getScriptName(), List.of(keystore.isValid() ? "descriptor-text" : "descriptor-error", keystore.getScriptName()));
|
||||
}
|
||||
|
||||
append(MULTISIG.getCloseDescriptor(), "descriptor-text");
|
||||
append(scriptType.getCloseDescriptor(), "descriptor-text");
|
||||
}
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
super.clear();
|
||||
this.wallet = null;
|
||||
setDisable(false);
|
||||
setContextMenu(null);
|
||||
}
|
||||
|
||||
private static class DescriptorContextMenu extends ContextMenu {
|
||||
public DescriptorContextMenu(Wallet wallet, DescriptorArea descriptorArea) {
|
||||
MenuItem copyvalue = new MenuItem("Copy Value");
|
||||
copyvalue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(descriptorArea.getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyvalue);
|
||||
|
||||
MenuItem copyOutputDescriptor = new MenuItem("Copy Output Descriptor");
|
||||
copyOutputDescriptor.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(OutputDescriptor.getOutputDescriptor(wallet).toString(true));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyOutputDescriptor);
|
||||
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: System; -fx-font-size: 1em;");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
@@ -31,10 +32,14 @@ import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DevicePane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
|
||||
|
||||
private final DeviceOperation deviceOperation;
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
@@ -199,7 +204,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation());
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
for(int i = 0; i < accounts.length; i++) {
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
@@ -258,7 +264,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void unlock(Device device) {
|
||||
if(device.getModel().equals(WalletModel.TREZOR_1)) {
|
||||
if(device.getModel().requiresPinPrompt()) {
|
||||
promptPin();
|
||||
}
|
||||
}
|
||||
@@ -465,6 +471,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
});
|
||||
signPSBTService.setOnFailed(workerStateEvent -> {
|
||||
setError("Signing Error", signPSBTService.getException().getMessage());
|
||||
log.error("Signing Error: " + signPSBTService.getException().getMessage());
|
||||
signButton.setDisable(false);
|
||||
});
|
||||
setDescription("Signing...");
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import tornadofx.control.Form;
|
||||
|
||||
public class DynamicForm extends Form {
|
||||
private DynamicUpdate dynamicUpdate;
|
||||
|
||||
public DynamicForm() {
|
||||
super();
|
||||
}
|
||||
|
||||
public void setDynamicUpdate(DynamicUpdate dynamicUpdate) {
|
||||
this.dynamicUpdate = dynamicUpdate;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutChildren() {
|
||||
if(dynamicUpdate != null) {
|
||||
dynamicUpdate.update();
|
||||
}
|
||||
|
||||
super.layoutChildren();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
public interface DynamicUpdate {
|
||||
void update();
|
||||
}
|
||||
@@ -74,7 +74,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
|
||||
setText(address.toString());
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), null));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setText(nodeEntry.getNode().getDerivationPath());
|
||||
tooltip.setText(nodeEntry.getNode().getDerivationPath().replace("m", ".."));
|
||||
setTooltip(tooltip);
|
||||
getStyleClass().add("address-cell");
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.FileImport;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
@@ -9,41 +13,75 @@ import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonBase;
|
||||
import javafx.scene.control.Control;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.stage.FileChooser;
|
||||
import javafx.stage.Stage;
|
||||
import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.control.textfield.CustomPasswordField;
|
||||
import org.controlsfx.control.textfield.TextFields;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.tools.Platform;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
private static final Logger log = LoggerFactory.getLogger(FileImportPane.class);
|
||||
|
||||
private final FileImport importer;
|
||||
protected Button importButton;
|
||||
protected ButtonBase importButton;
|
||||
private final SimpleStringProperty password = new SimpleStringProperty("");
|
||||
private final boolean scannable;
|
||||
protected List<Wallet> wallets;
|
||||
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl) {
|
||||
public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) {
|
||||
super(title, description, content, imageUrl);
|
||||
this.importer = importer;
|
||||
this.scannable = scannable;
|
||||
|
||||
buttonBox.getChildren().clear();
|
||||
buttonBox.getChildren().add(createButton());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Control createButton() {
|
||||
importButton = new Button("Import File...");
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setOnAction(event -> {
|
||||
importFile();
|
||||
});
|
||||
return importButton;
|
||||
if(scannable) {
|
||||
ToggleButton scanButton = new ToggleButton("Scan...");
|
||||
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
|
||||
cameraGlyph.setFontSize(12);
|
||||
scanButton.setGraphic(cameraGlyph);
|
||||
scanButton.setOnAction(event -> {
|
||||
scanButton.setSelected(false);
|
||||
importQR();
|
||||
});
|
||||
|
||||
ToggleButton fileButton = new ToggleButton("Import File...");
|
||||
fileButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
fileButton.setOnAction(event -> {
|
||||
fileButton.setSelected(false);
|
||||
importFile();
|
||||
});
|
||||
importButton = fileButton;
|
||||
|
||||
SegmentedButton segmentedButton = new SegmentedButton();
|
||||
segmentedButton.getButtons().addAll(scanButton, fileButton);
|
||||
return segmentedButton;
|
||||
} else {
|
||||
importButton = new Button("Import File...");
|
||||
importButton.setAlignment(Pos.CENTER_RIGHT);
|
||||
importButton.setOnAction(event -> {
|
||||
importFile();
|
||||
});
|
||||
return importButton;
|
||||
}
|
||||
}
|
||||
|
||||
private void importFile() {
|
||||
@@ -52,8 +90,9 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*.*"),
|
||||
new FileChooser.ExtensionFilter("JSON", "*.json")
|
||||
new FileChooser.ExtensionFilter("All Files", Platform.getCurrent().equals(Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("JSON", "*.json"),
|
||||
new FileChooser.ExtensionFilter("TXT", "*.txt")
|
||||
);
|
||||
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
@@ -93,6 +132,53 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
}
|
||||
}
|
||||
|
||||
private void importQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.wallets != null) {
|
||||
wallets = result.wallets;
|
||||
try {
|
||||
importFile(importer.getName(), null, null);
|
||||
} catch(ImportException e) {
|
||||
setError("Import Error", e.getMessage());
|
||||
}
|
||||
} else if(result.payload != null) {
|
||||
try {
|
||||
importFile(importer.getName(), new ByteArrayInputStream(result.payload.getBytes(StandardCharsets.UTF_8)), null);
|
||||
} catch(Exception e) {
|
||||
log.error("Error importing QR", e);
|
||||
String errorMessage = e.getMessage();
|
||||
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
|
||||
errorMessage = e.getCause().getMessage();
|
||||
}
|
||||
if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) {
|
||||
errorMessage = "QR contents were not in JSON format";
|
||||
}
|
||||
setError("Import Error", errorMessage);
|
||||
}
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error importing QR", result.exception);
|
||||
setError("Import Error", result.exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Keystore getScannedKeystore(ScriptType scriptType) throws ImportException {
|
||||
if(wallets != null) {
|
||||
for(Wallet wallet : wallets) {
|
||||
if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
|
||||
return wallet.getKeystores().get(0);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ImportException("Script type " + scriptType + " is not supported");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException;
|
||||
|
||||
private Node getPasswordEntry(File file) {
|
||||
@@ -102,6 +188,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
HBox.setHgrow(passwordField, Priority.ALWAYS);
|
||||
|
||||
Button importEncryptedButton = new Button("Import");
|
||||
importEncryptedButton.setDefaultButton(true);
|
||||
importEncryptedButton.setOnAction(event -> {
|
||||
showHideLink.setVisible(true);
|
||||
setExpanded(false);
|
||||
|
||||
@@ -14,13 +14,17 @@ public class FileKeystoreImportPane extends FileImportPane {
|
||||
private final KeystoreFileImport importer;
|
||||
|
||||
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Keystore file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
|
||||
this.wallet = wallet;
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
|
||||
Keystore keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password);
|
||||
Keystore keystore = getScannedKeystore(wallet.getScriptType());
|
||||
if(keystore == null) {
|
||||
keystore = importer.getKeystore(wallet.getScriptType(), inputStream, password);
|
||||
}
|
||||
|
||||
EventManager.get().post(new KeystoreImportEvent(keystore));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane {
|
||||
private final WalletImport importer;
|
||||
|
||||
public FileWalletImportPane(WalletImport importer) {
|
||||
super(importer, importer.getName(), "Wallet file import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
|
||||
private byte[] fileBytes;
|
||||
|
||||
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
|
||||
super(importer, importer.getName(), "Wallet file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
|
||||
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ public class HelpLabel extends Label {
|
||||
private StringProperty helpText;
|
||||
|
||||
public final void setHelpText(String value) {
|
||||
helpTextProperty().setValue(value);
|
||||
helpTextProperty().setValue(value.replace("\\n", "\n"));
|
||||
}
|
||||
|
||||
public final String getHelpText() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
@@ -107,7 +108,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
|
||||
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
|
||||
});
|
||||
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
|
||||
for(int i = 0; i < accounts.length; i++) {
|
||||
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
|
||||
for(int i = 0; i < scriptAccountsLength; i++) {
|
||||
MenuItem item = new MenuItem(accounts[i]);
|
||||
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
|
||||
item.setOnAction(event -> {
|
||||
|
||||
@@ -7,8 +7,8 @@ import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import com.sparrowwallet.sparrow.ur.UR;
|
||||
import com.sparrowwallet.sparrow.ur.UREncoder;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.UREncoder;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
@@ -32,6 +32,8 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
private static final int MIN_FRAGMENT_LENGTH = 10;
|
||||
private static final int MAX_FRAGMENT_LENGTH = 100;
|
||||
|
||||
private static final int ANIMATION_PERIOD_MILLIS = 200;
|
||||
|
||||
private final UR ur;
|
||||
private final UREncoder encoder;
|
||||
|
||||
@@ -39,8 +41,8 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
private String currentPart;
|
||||
|
||||
public QRDisplayDialog(byte[] data) {
|
||||
this(UR.fromBytes(data));
|
||||
public QRDisplayDialog(String type, byte[] data) throws UR.URException {
|
||||
this(UR.fromBytes(type, data));
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur) {
|
||||
@@ -54,14 +56,14 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
qrImageView = new ImageView();
|
||||
stackPane.getChildren().add(qrImageView);
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().outerPadding(0).innerPadding(0).buildAll());
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
|
||||
nextPart();
|
||||
if(encoder.isSinglePart()) {
|
||||
qrImageView.setImage(getQrCode(currentPart));
|
||||
} else {
|
||||
AnimateQRService animateQRService = new AnimateQRService();
|
||||
animateQRService.setPeriod(Duration.millis(100));
|
||||
animateQRService.setPeriod(Duration.millis(ANIMATION_PERIOD_MILLIS));
|
||||
animateQRService.start();
|
||||
setOnCloseRequest(event -> {
|
||||
animateQRService.cancel();
|
||||
@@ -76,6 +78,28 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(String data) {
|
||||
this.ur = null;
|
||||
this.encoder = null;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppController.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
qrImageView = new ImageView();
|
||||
stackPane.getChildren().add(qrImageView);
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
qrImageView.setImage(getQrCode(data));
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(550);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
}
|
||||
|
||||
private void nextPart() {
|
||||
String fragment = encoder.nextPart();
|
||||
currentPart = fragment.toUpperCase();
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.github.sarxos.webcam.WebcamResolution;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
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.protocol.Base43;
|
||||
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.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.hummingbird.registry.*;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.ur.ResultType;
|
||||
import com.sparrowwallet.sparrow.ur.UR;
|
||||
import com.sparrowwallet.sparrow.ur.URDecoder;
|
||||
import com.sparrowwallet.hummingbird.ResultType;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.URDecoder;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
@@ -20,14 +31,26 @@ import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.DialogPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.controlsfx.tools.Borders;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
|
||||
|
||||
private final URDecoder decoder;
|
||||
private final WebcamService webcamService;
|
||||
private List<String> parts;
|
||||
|
||||
private boolean isUr;
|
||||
private QRScanDialog.Result result;
|
||||
|
||||
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
|
||||
|
||||
public QRScanDialog() {
|
||||
this.decoder = new URDecoder();
|
||||
|
||||
@@ -40,7 +63,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
StackPane stackPane = new StackPane();
|
||||
stackPane.getChildren().add(webcamView.getView());
|
||||
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().outerPadding(0).innerPadding(0).buildAll());
|
||||
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
|
||||
|
||||
webcamService.resultProperty().addListener(new QRResultListener());
|
||||
webcamService.setOnFailed(failedEvent -> {
|
||||
@@ -68,6 +91,8 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
|
||||
//Try text first
|
||||
String qrtext = qrResult.getText();
|
||||
Matcher partMatcher = PART_PATTERN.matcher(qrtext);
|
||||
|
||||
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
|
||||
isUr = true;
|
||||
decoder.receivePart(qrtext);
|
||||
@@ -75,37 +100,56 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
if(decoder.getResult() != null) {
|
||||
URDecoder.Result urResult = decoder.getResult();
|
||||
if(urResult.type == ResultType.SUCCESS) {
|
||||
//TODO: Confirm once UR type registry is updated
|
||||
if(urResult.ur.getType().contains(UR.BYTES_TYPE) || urResult.ur.getType().equals(UR.CRYPTO_PSBT_TYPE)) {
|
||||
try {
|
||||
PSBT psbt = new PSBT(urResult.ur.toBytes());
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(urResult.ur.toBytes());
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as tx
|
||||
}
|
||||
|
||||
result = new Result("Parsed UR of type " + urResult.ur.getType() + " was not a PSBT or transaction");
|
||||
} else {
|
||||
result = new Result("Cannot parse UR type of " + urResult.ur.getType());
|
||||
}
|
||||
result = extractResultFromUR(urResult.ur);
|
||||
} else {
|
||||
result = new Result(urResult.error);
|
||||
result = new Result(new URException(urResult.error));
|
||||
}
|
||||
}
|
||||
} else if(partMatcher.matches()) {
|
||||
int m = Integer.parseInt(partMatcher.group(1));
|
||||
int n = Integer.parseInt(partMatcher.group(2));
|
||||
String payload = partMatcher.group(3);
|
||||
|
||||
if(parts == null) {
|
||||
parts = new ArrayList<>(n);
|
||||
IntStream.range(0, n).forEach(i -> parts.add(null));
|
||||
}
|
||||
parts.set(m - 1, payload);
|
||||
|
||||
if(parts.stream().filter(Objects::nonNull).count() == n) {
|
||||
String complete = String.join("", parts);
|
||||
try {
|
||||
PSBT psbt = PSBT.fromString(complete);
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(Utils.hexToBytes(complete));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as tx
|
||||
}
|
||||
|
||||
result = new Result(new ScanException("Parsed QR parts were not a PSBT or transaction"));
|
||||
}
|
||||
} else {
|
||||
PSBT psbt;
|
||||
Transaction transaction;
|
||||
BitcoinURI bitcoinURI;
|
||||
Address address;
|
||||
ExtendedKey extendedKey;
|
||||
try {
|
||||
extendedKey = ExtendedKey.fromDescriptor(qrtext);
|
||||
result = new Result(extendedKey);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not a valid xpub
|
||||
}
|
||||
|
||||
try {
|
||||
bitcoinURI = new BitcoinURI(qrtext);
|
||||
result = new Result(bitcoinURI);
|
||||
@@ -156,7 +200,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
|
||||
//Try Base43 used by Electrum
|
||||
try {
|
||||
psbt = new PSBT(Base43.decode(qrResult.getText()));
|
||||
psbt = new PSBT(Base43.decode(qrtext));
|
||||
result = new Result(psbt);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
@@ -164,30 +208,210 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
}
|
||||
|
||||
try {
|
||||
transaction = new Transaction(Base43.decode(qrResult.getText()));
|
||||
transaction = new Transaction(Base43.decode(qrtext));
|
||||
result = new Result(transaction);
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//Ignore, not parseable as base43 decoded bytes
|
||||
}
|
||||
|
||||
result = new Result("Cannot parse QR code into a PSBT, transaction or address");
|
||||
result = new Result(qrtext);
|
||||
}
|
||||
}
|
||||
|
||||
private Result extractResultFromUR(UR ur) {
|
||||
try {
|
||||
RegistryType urRegistryType = ur.getRegistryType();
|
||||
|
||||
if(urRegistryType.equals(RegistryType.BYTES)) {
|
||||
byte[] urBytes = (byte[])ur.decodeFromRegistry();
|
||||
try {
|
||||
PSBT psbt = new PSBT(urBytes);
|
||||
return new Result(psbt);
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as PSBT
|
||||
}
|
||||
|
||||
try {
|
||||
Transaction transaction = new Transaction(urBytes);
|
||||
return new Result(transaction);
|
||||
} catch(Exception e) {
|
||||
//ignore, bytes not parsable as tx
|
||||
}
|
||||
|
||||
result = new Result(new URException("Parsed UR of type " + urRegistryType + " was not a PSBT or transaction"));
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_PSBT)) {
|
||||
CryptoPSBT cryptoPSBT = (CryptoPSBT)ur.decodeFromRegistry();
|
||||
try {
|
||||
PSBT psbt = new PSBT(cryptoPSBT.getPsbt());
|
||||
return new Result(psbt);
|
||||
} catch(Exception e) {
|
||||
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
|
||||
return new Result(new URException("Error parsing PSBT from UR type " + urRegistryType, e));
|
||||
}
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_ADDRESS)) {
|
||||
CryptoAddress cryptoAddress = (CryptoAddress)ur.decodeFromRegistry();
|
||||
Address address = getAddress(cryptoAddress);
|
||||
if(address != null) {
|
||||
return new Result(BitcoinURI.fromAddress(address));
|
||||
} else {
|
||||
return new Result(new URException("Unknown " + urRegistryType + " type of " + cryptoAddress.getType()));
|
||||
}
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_HDKEY)) {
|
||||
CryptoHDKey cryptoHDKey = (CryptoHDKey)ur.decodeFromRegistry();
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
return new Result(extendedKey);
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_OUTPUT)) {
|
||||
CryptoOutput cryptoOutput = (CryptoOutput)ur.decodeFromRegistry();
|
||||
OutputDescriptor outputDescriptor = getOutputDescriptor(cryptoOutput);
|
||||
return new Result(outputDescriptor);
|
||||
} else if(urRegistryType.equals(RegistryType.CRYPTO_ACCOUNT)) {
|
||||
CryptoAccount cryptoAccount = (CryptoAccount)ur.decodeFromRegistry();
|
||||
List<Wallet> wallets = getWallets(cryptoAccount);
|
||||
return new Result(wallets);
|
||||
} else {
|
||||
log.error("Unsupported UR type " + urRegistryType);
|
||||
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
|
||||
}
|
||||
} catch(IllegalArgumentException e) {
|
||||
log.error("Unknown UR type of " + ur.getType(), e);
|
||||
return new Result(new URException("Unknown UR type of " + ur.getType(), e));
|
||||
} catch(UR.InvalidCBORException e) {
|
||||
log.error("Invalid CBOR in UR", e);
|
||||
return new Result(new URException("Invalid CBOR in UR", e));
|
||||
} catch(Exception e) {
|
||||
log.error("Error parsing UR CBOR", e);
|
||||
return new Result(new URException("Error parsing UR CBOR", e));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Address getAddress(CryptoAddress cryptoAddress) {
|
||||
Address address = null;
|
||||
if(cryptoAddress.getType() == null || cryptoAddress.getType() == CryptoAddress.Type.P2PKH) {
|
||||
address = new P2PKHAddress(cryptoAddress.getData());
|
||||
} else if(cryptoAddress.getType() == CryptoAddress.Type.P2SH) {
|
||||
address = new P2SHAddress(cryptoAddress.getData());
|
||||
} else if(cryptoAddress.getType() == CryptoAddress.Type.P2WPKH) {
|
||||
address = new P2WPKHAddress(cryptoAddress.getData());
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
private ExtendedKey getExtendedKey(CryptoHDKey cryptoHDKey) {
|
||||
if(cryptoHDKey.isPrivateKey()) {
|
||||
DeterministicKey prvKey = HDKeyDerivation.createMasterPrivKeyFromBytes(Arrays.copyOfRange(cryptoHDKey.getKey(), 1, 33), cryptoHDKey.getChainCode(), List.of(ChildNumber.ZERO));
|
||||
return new ExtendedKey(prvKey, new byte[4], ChildNumber.ZERO);
|
||||
} else {
|
||||
ChildNumber lastChild = ChildNumber.ZERO;
|
||||
int depth = 1;
|
||||
byte[] parentFingerprint = new byte[4];
|
||||
if(cryptoHDKey.getOrigin() != null) {
|
||||
if(!cryptoHDKey.getOrigin().getComponents().isEmpty()) {
|
||||
PathComponent lastComponent = cryptoHDKey.getOrigin().getComponents().get(cryptoHDKey.getOrigin().getComponents().size() - 1);
|
||||
lastChild = new ChildNumber(lastComponent.getIndex(), lastComponent.isHardened());
|
||||
depth = cryptoHDKey.getOrigin().getComponents().size();
|
||||
}
|
||||
if(cryptoHDKey.getOrigin().getParentFingerprint() != null) {
|
||||
parentFingerprint = cryptoHDKey.getOrigin().getParentFingerprint();
|
||||
}
|
||||
}
|
||||
DeterministicKey pubKey = new DeterministicKey(List.of(lastChild), cryptoHDKey.getChainCode(), cryptoHDKey.getKey(), depth, parentFingerprint);
|
||||
return new ExtendedKey(pubKey, parentFingerprint, lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
private OutputDescriptor getOutputDescriptor(CryptoOutput cryptoOutput) {
|
||||
ScriptType scriptType = getScriptType(cryptoOutput.getScriptExpressions());
|
||||
|
||||
if(cryptoOutput.getMultiKey() != null) {
|
||||
MultiKey multiKey = cryptoOutput.getMultiKey();
|
||||
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
|
||||
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
|
||||
extendedPublicKeys.put(extendedKey, keyDerivation);
|
||||
}
|
||||
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys);
|
||||
} else if(cryptoOutput.getEcKey() != null) {
|
||||
throw new IllegalArgumentException("EC keys are currently unsupported");
|
||||
} else if(cryptoOutput.getHdKey() != null) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
|
||||
return new OutputDescriptor(scriptType, extendedKey, keyDerivation);
|
||||
}
|
||||
|
||||
throw new IllegalStateException("CryptoOutput did not contain sufficient information");
|
||||
}
|
||||
|
||||
private List<Wallet> getWallets(CryptoAccount cryptoAccount) {
|
||||
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, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath()));
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallets.add(wallet);
|
||||
}
|
||||
|
||||
return wallets;
|
||||
}
|
||||
|
||||
private ScriptType getScriptType(List<ScriptExpression> expressions) {
|
||||
if(List.of(ScriptExpression.PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2PKH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2SH_P2WPKH;
|
||||
} else if(List.of(ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
|
||||
return ScriptType.P2WPKH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH).equals(expressions)) {
|
||||
return ScriptType.P2SH;
|
||||
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
|
||||
return ScriptType.P2SH_P2WSH;
|
||||
} else if(List.of(ScriptExpression.WITNESS_SCRIPT_HASH).equals(expressions)) {
|
||||
return ScriptType.P2WSH;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Unknown script of " + expressions);
|
||||
}
|
||||
|
||||
private KeyDerivation getKeyDerivation(CryptoKeypath cryptoKeypath) {
|
||||
if(cryptoKeypath != null) {
|
||||
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getParentFingerprint()), cryptoKeypath.getPath());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Result {
|
||||
public final Transaction transaction;
|
||||
public final PSBT psbt;
|
||||
public final BitcoinURI uri;
|
||||
public final String error;
|
||||
public final ExtendedKey extendedKey;
|
||||
public final OutputDescriptor outputDescriptor;
|
||||
public final List<Wallet> wallets;
|
||||
public final String payload;
|
||||
public final Throwable exception;
|
||||
|
||||
public Result(Transaction transaction) {
|
||||
this.transaction = transaction;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.error = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
@@ -195,7 +419,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.transaction = null;
|
||||
this.psbt = psbt;
|
||||
this.uri = null;
|
||||
this.error = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
@@ -203,7 +430,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = uri;
|
||||
this.error = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
@@ -211,15 +441,54 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = BitcoinURI.fromAddress(address);
|
||||
this.error = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(String error) {
|
||||
public Result(ExtendedKey extendedKey) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.error = error;
|
||||
this.extendedKey = extendedKey;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(OutputDescriptor outputDescriptor) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = outputDescriptor;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(List<Wallet> wallets) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = wallets;
|
||||
this.payload = null;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
public Result(String payload) {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = payload;
|
||||
this.exception = null;
|
||||
}
|
||||
|
||||
@@ -227,8 +496,47 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
this.transaction = null;
|
||||
this.psbt = null;
|
||||
this.uri = null;
|
||||
this.error = null;
|
||||
this.extendedKey = null;
|
||||
this.outputDescriptor = null;
|
||||
this.wallets = null;
|
||||
this.payload = null;
|
||||
this.exception = exception;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ScanException extends Exception {
|
||||
public ScanException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public ScanException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ScanException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public ScanException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
public static class URException extends ScanException {
|
||||
public URException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public URException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public URException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public URException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
import com.sparrowwallet.sparrow.transaction.ScriptContextMenu;
|
||||
import javafx.geometry.Pos;
|
||||
import org.controlsfx.control.decoration.Decorator;
|
||||
import org.controlsfx.control.decoration.GraphicDecoration;
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.sparrowwallet.sparrow.transaction;
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptChunk;
|
||||
@@ -33,7 +33,7 @@ public class ScriptContextMenu extends ContextMenu {
|
||||
});
|
||||
|
||||
getItems().add(copyvalue);
|
||||
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: sans-serif; -fx-font-size: 1em;");
|
||||
this.setStyle("-fx-background-color: -fx-color; -fx-font-family: System; -fx-font-size: 1em;");
|
||||
|
||||
area.addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> {
|
||||
hoverChunk = null;
|
||||
@@ -5,6 +5,7 @@ import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
|
||||
public class TextAreaDialog extends Dialog<String> {
|
||||
private final TextArea textArea;
|
||||
@@ -21,11 +22,14 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
this.textArea = new TextArea(defaultValue);
|
||||
this.textArea.setMaxWidth(Double.MAX_VALUE);
|
||||
this.textArea.setWrapText(true);
|
||||
this.textArea.getStyleClass().add("fixed-width");
|
||||
hbox.getChildren().add(textArea);
|
||||
HBox.setHgrow(this.textArea, Priority.ALWAYS);
|
||||
|
||||
this.defaultValue = defaultValue;
|
||||
|
||||
dialogPane.setContent(hbox);
|
||||
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
|
||||
AppController.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
dialogPane.getStyleClass().add("text-input-dialog");
|
||||
@@ -37,6 +41,9 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
|
||||
return data == ButtonBar.ButtonData.OK_DONE ? textArea.getText() : null;
|
||||
});
|
||||
|
||||
dialogPane.setPrefWidth(700);
|
||||
dialogPane.setPrefHeight(400);
|
||||
}
|
||||
|
||||
public final TextArea getEditor() {
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTransaction;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Group;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.scene.shape.CubicCurve;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.util.Duration;
|
||||
import org.controlsfx.glyphfont.FontAwesome;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TransactionDiagram extends GridPane {
|
||||
private static final int MAX_UTXOS = 8;
|
||||
private static final int MAX_PAYMENTS = 6;
|
||||
private static final double DIAGRAM_HEIGHT = 230.0;
|
||||
private static final int TOOLTIP_SHOW_DELAY = 50;
|
||||
|
||||
private WalletTransaction walletTx;
|
||||
|
||||
@@ -49,10 +61,12 @@ public class TransactionDiagram extends GridPane {
|
||||
Pane txPane = getTransactionPane();
|
||||
GridPane.setConstraints(txPane, 3, 0);
|
||||
|
||||
Pane outputsLinesPane = getOutputsLines();
|
||||
List<Payment> displayedPayments = getDisplayedPayments();
|
||||
|
||||
Pane outputsLinesPane = getOutputsLines(displayedPayments);
|
||||
GridPane.setConstraints(outputsLinesPane, 4, 0);
|
||||
|
||||
Pane outputsPane = getOutputsLabels();
|
||||
Pane outputsPane = getOutputsLabels(displayedPayments);
|
||||
GridPane.setConstraints(outputsPane, 5, 0);
|
||||
|
||||
getChildren().clear();
|
||||
@@ -62,11 +76,16 @@ public class TransactionDiagram extends GridPane {
|
||||
private Map<BlockTransactionHashIndex, WalletNode> getDisplayedUtxos() {
|
||||
Map<BlockTransactionHashIndex, WalletNode> selectedUtxos = walletTx.getSelectedUtxos();
|
||||
|
||||
if(getPayjoinURI() != null) {
|
||||
selectedUtxos = new LinkedHashMap<>(selectedUtxos);
|
||||
selectedUtxos.put(new PayjoinBlockTransactionHashIndex(), null);
|
||||
}
|
||||
|
||||
if(selectedUtxos.size() > MAX_UTXOS) {
|
||||
Map<BlockTransactionHashIndex, WalletNode> utxos = new LinkedHashMap<>();
|
||||
List<BlockTransactionHashIndex> additional = new ArrayList<>();
|
||||
for(BlockTransactionHashIndex reference : selectedUtxos.keySet()) {
|
||||
if (utxos.size() < MAX_UTXOS) {
|
||||
if(utxos.size() < MAX_UTXOS - 1) {
|
||||
utxos.put(reference, selectedUtxos.get(reference));
|
||||
} else {
|
||||
additional.add(reference);
|
||||
@@ -80,6 +99,22 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
private BitcoinURI getPayjoinURI() {
|
||||
for(Payment payment : walletTx.getPayments()) {
|
||||
try {
|
||||
Address address = payment.getAddress();
|
||||
BitcoinURI bitcoinURI = AppController.getPayjoinURI(address);
|
||||
if(bitcoinURI != null) {
|
||||
return bitcoinURI;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Pane getInputsType(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
|
||||
StackPane stackPane = new StackPane();
|
||||
|
||||
@@ -146,24 +181,39 @@ public class TransactionDiagram extends GridPane {
|
||||
WalletNode walletNode = displayedUtxos.get(input);
|
||||
String desc = getInputDescription(input);
|
||||
Label label = new Label(desc);
|
||||
label.getStyleClass().add("utxo-label");
|
||||
|
||||
Button excludeUtxoButton = new Button("");
|
||||
excludeUtxoButton.setGraphic(getExcludeGlyph());
|
||||
excludeUtxoButton.setOnAction(event -> {
|
||||
EventManager.get().post(new ExcludeUtxoEvent(walletTx, input));
|
||||
});
|
||||
|
||||
Tooltip tooltip = new Tooltip();
|
||||
if(walletNode != null) {
|
||||
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath() + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
|
||||
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath().replace("m", "..") + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
|
||||
tooltip.getStyleClass().add("input-label");
|
||||
|
||||
if(input.getLabel() == null || input.getLabel().isEmpty()) {
|
||||
label.getStyleClass().add("input-label");
|
||||
} else {
|
||||
tooltip.getStyleClass().add("input-label");
|
||||
}
|
||||
|
||||
label.setGraphic(excludeUtxoButton);
|
||||
label.setContentDisplay(ContentDisplay.LEFT);
|
||||
} else {
|
||||
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex)input;
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
|
||||
joiner.add(getInputDescription(additionalInput));
|
||||
if(input instanceof PayjoinBlockTransactionHashIndex) {
|
||||
tooltip.setText("Added once transaction is signed and sent to the payjoin server");
|
||||
} else {
|
||||
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex) input;
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for(BlockTransactionHashIndex additionalInput : additionalReference.getAdditionalInputs()) {
|
||||
joiner.add(getInputDescription(additionalInput));
|
||||
}
|
||||
tooltip.setText(joiner.toString());
|
||||
}
|
||||
tooltip.setText(joiner.toString());
|
||||
tooltip.getStyleClass().add("input-label");
|
||||
}
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
label.setTooltip(tooltip);
|
||||
|
||||
inputsBox.getChildren().add(label);
|
||||
@@ -195,11 +245,16 @@ public class TransactionDiagram extends GridPane {
|
||||
group.getChildren().add(yaxisLine);
|
||||
|
||||
double width = 140.0;
|
||||
List<BlockTransactionHashIndex> inputs = new ArrayList<>(displayedUtxos.keySet());
|
||||
int numUtxos = displayedUtxos.size();
|
||||
for(int i = 1; i <= numUtxos; i++) {
|
||||
CubicCurve curve = new CubicCurve();
|
||||
curve.getStyleClass().add("input-line");
|
||||
|
||||
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
|
||||
curve.getStyleClass().add("input-dashed-line");
|
||||
}
|
||||
|
||||
curve.setStartX(0);
|
||||
double scaleFactor = (double)i / (numUtxos + 1);
|
||||
int nodeHeight = 17;
|
||||
@@ -224,7 +279,28 @@ public class TransactionDiagram extends GridPane {
|
||||
return value * (1.0 - scaleFactor) + additional;
|
||||
}
|
||||
|
||||
private Pane getOutputsLines() {
|
||||
private List<Payment> getDisplayedPayments() {
|
||||
List<Payment> payments = walletTx.getPayments();
|
||||
|
||||
if(payments.size() > MAX_PAYMENTS) {
|
||||
List<Payment> displayedPayments = new ArrayList<>();
|
||||
List<Payment> additional = new ArrayList<>();
|
||||
for(Payment payment : payments) {
|
||||
if(displayedPayments.size() < MAX_PAYMENTS - 1) {
|
||||
displayedPayments.add(payment);
|
||||
} else {
|
||||
additional.add(payment);
|
||||
}
|
||||
}
|
||||
|
||||
displayedPayments.add(new AdditionalPayment(additional));
|
||||
return displayedPayments;
|
||||
} else {
|
||||
return payments;
|
||||
}
|
||||
}
|
||||
|
||||
private Pane getOutputsLines(List<Payment> displayedPayments) {
|
||||
VBox pane = new VBox();
|
||||
Group group = new Group();
|
||||
VBox.setVgrow(group, Priority.ALWAYS);
|
||||
@@ -238,7 +314,7 @@ public class TransactionDiagram extends GridPane {
|
||||
group.getChildren().add(yaxisLine);
|
||||
|
||||
double width = 140.0;
|
||||
int numOutputs = (walletTx.getChangeNode() == null ? 2 : 3);
|
||||
int numOutputs = displayedPayments.size() + (walletTx.getChangeNode() == null ? 1 : 2);
|
||||
for(int i = 1; i <= numOutputs; i++) {
|
||||
CubicCurve curve = new CubicCurve();
|
||||
curve.getStyleClass().add("output-line");
|
||||
@@ -263,27 +339,34 @@ public class TransactionDiagram extends GridPane {
|
||||
return pane;
|
||||
}
|
||||
|
||||
private Pane getOutputsLabels() {
|
||||
private Pane getOutputsLabels(List<Payment> displayedPayments) {
|
||||
VBox outputsBox = new VBox();
|
||||
outputsBox.setMaxWidth(150);
|
||||
outputsBox.setPadding(new Insets(0, 20, 0, 10));
|
||||
outputsBox.setAlignment(Pos.CENTER_LEFT);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
|
||||
boolean isConsolidation = walletTx.isConsolidationSend();
|
||||
String recipientDesc = walletTx.getRecipientAddress().toString().substring(0, 8) + "...";
|
||||
Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph());
|
||||
recipientLabel.getStyleClass().addAll("output-label", "recipient-label");
|
||||
Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(walletTx.getRecipientAmount()) + " sats to\n" + walletTx.getRecipientAddress().toString());
|
||||
recipientLabel.setTooltip(recipientTooltip);
|
||||
outputsBox.getChildren().add(recipientLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
for(Payment payment : displayedPayments) {
|
||||
boolean isConsolidation = walletTx.isConsolidationSend(payment);
|
||||
String recipientDesc = payment instanceof AdditionalPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "...";
|
||||
Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph());
|
||||
recipientLabel.getStyleClass().add("output-label");
|
||||
recipientLabel.getStyleClass().add(payment instanceof AdditionalPayment ? "additional-label" : "recipient-label");
|
||||
Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString()));
|
||||
recipientTooltip.getStyleClass().add("recipient-label");
|
||||
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
recipientLabel.setTooltip(recipientTooltip);
|
||||
outputsBox.getChildren().add(recipientLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
}
|
||||
|
||||
if(walletTx.getChangeNode() != null) {
|
||||
String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "...";
|
||||
Label changeLabel = new Label(changeDesc, getChangeGlyph());
|
||||
changeLabel.getStyleClass().addAll("output-label", "change-label");
|
||||
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath() + "\n" + walletTx.getChangeAddress().toString());
|
||||
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString());
|
||||
changeTooltip.getStyleClass().add("change-label");
|
||||
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
changeLabel.setTooltip(changeTooltip);
|
||||
outputsBox.getChildren().add(changeLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
@@ -295,6 +378,7 @@ public class TransactionDiagram extends GridPane {
|
||||
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
|
||||
Tooltip feeTooltip = new Tooltip("Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
|
||||
feeTooltip.getStyleClass().add("fee-tooltip");
|
||||
feeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
feeLabel.setTooltip(feeTooltip);
|
||||
outputsBox.getChildren().add(feeLabel);
|
||||
outputsBox.getChildren().add(createSpacer());
|
||||
@@ -311,6 +395,8 @@ public class TransactionDiagram extends GridPane {
|
||||
String txDesc = "Transaction";
|
||||
Label txLabel = new Label(txDesc);
|
||||
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + walletTx.getTransaction().getVirtualSize() + " vBytes");
|
||||
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
tooltip.getStyleClass().add("transaction-tooltip");
|
||||
txLabel.setTooltip(tooltip);
|
||||
txPane.getChildren().add(txLabel);
|
||||
txPane.getChildren().add(createSpacer());
|
||||
@@ -324,6 +410,13 @@ public class TransactionDiagram extends GridPane {
|
||||
return spacer;
|
||||
}
|
||||
|
||||
public static Glyph getExcludeGlyph() {
|
||||
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
|
||||
excludeGlyph.getStyleClass().add("exclude-utxo");
|
||||
excludeGlyph.setFontSize(12);
|
||||
return excludeGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getPaymentGlyph() {
|
||||
Glyph paymentGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
|
||||
paymentGlyph.getStyleClass().add("payment-icon");
|
||||
@@ -332,40 +425,62 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
|
||||
public static Glyph getConsolidationGlyph() {
|
||||
Glyph consolidationGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.REPLY_ALL);
|
||||
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
|
||||
consolidationGlyph.getStyleClass().add("consolidation-icon");
|
||||
consolidationGlyph.setFontSize(12);
|
||||
return consolidationGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getTxoGlyph() {
|
||||
return getChangeGlyph();
|
||||
}
|
||||
|
||||
public static Glyph getPayjoinGlyph() {
|
||||
Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
|
||||
payjoinGlyph.getStyleClass().add("payjoin-icon");
|
||||
payjoinGlyph.setFontSize(12);
|
||||
return payjoinGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getChangeGlyph() {
|
||||
Glyph changeGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.COINS);
|
||||
Glyph changeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
|
||||
changeGlyph.getStyleClass().add("change-icon");
|
||||
changeGlyph.setFontSize(12);
|
||||
return changeGlyph;
|
||||
}
|
||||
|
||||
private Glyph getFeeGlyph() {
|
||||
Glyph feeGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.HAND_HOLDING);
|
||||
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
|
||||
feeGlyph.getStyleClass().add("fee-icon");
|
||||
feeGlyph.setFontSize(12);
|
||||
return feeGlyph;
|
||||
}
|
||||
|
||||
private Glyph getWarningGlyph() {
|
||||
Glyph feeWarningGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
||||
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
|
||||
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
|
||||
feeWarningGlyph.setFontSize(12);
|
||||
return feeWarningGlyph;
|
||||
}
|
||||
|
||||
private Glyph getLockGlyph() {
|
||||
Glyph lockGlyph = new Glyph("Font Awesome 5 Free Solid", FontAwesome5.Glyph.LOCK);
|
||||
Glyph lockGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.LOCK);
|
||||
lockGlyph.getStyleClass().add("lock-icon");
|
||||
lockGlyph.setFontSize(12);
|
||||
return lockGlyph;
|
||||
}
|
||||
|
||||
private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex {
|
||||
public PayjoinBlockTransactionHashIndex() {
|
||||
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel() {
|
||||
return "Payjoin input";
|
||||
}
|
||||
}
|
||||
|
||||
private static class AdditionalBlockTransactionHashIndex extends BlockTransactionHashIndex {
|
||||
private final List<BlockTransactionHashIndex> additionalInputs;
|
||||
|
||||
@@ -383,4 +498,17 @@ public class TransactionDiagram extends GridPane {
|
||||
return additionalInputs;
|
||||
}
|
||||
}
|
||||
|
||||
private static class AdditionalPayment extends Payment {
|
||||
private final List<Payment> additionalPayments;
|
||||
|
||||
public AdditionalPayment(List<Payment> additionalPayments) {
|
||||
super(null, additionalPayments.size() + " more...", additionalPayments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(), false);
|
||||
this.additionalPayments = additionalPayments;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public class UtxosChart extends BarChart<String, Number> {
|
||||
public void update(WalletUtxosEntry walletUtxosEntry) {
|
||||
List<Data<String, Number>> utxoDataList = walletUtxosEntry.getChildren().stream()
|
||||
.map(entry -> new XYChart.Data<>(getCategoryName(entry), (Number)entry.getValue(), entry))
|
||||
.sorted((o1, o2) -> (int) (o2.getYValue().longValue() - o1.getYValue().longValue()))
|
||||
.sorted((o1, o2) -> Long.compare(o2.getYValue().longValue(), o1.getYValue().longValue()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
totalUtxos = utxoDataList.size();
|
||||
|
||||
@@ -6,9 +6,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletExportEvent;
|
||||
import com.sparrowwallet.sparrow.io.ColdcardMultisig;
|
||||
import com.sparrowwallet.sparrow.io.Electrum;
|
||||
import com.sparrowwallet.sparrow.io.WalletExport;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
@@ -34,7 +32,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
stackPane.getChildren().add(anchorPane);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setPrefHeight(280);
|
||||
scrollPane.setPrefHeight(400);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
@@ -43,9 +41,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
|
||||
List<WalletExport> exporters;
|
||||
if(wallet.getPolicyType() == PolicyType.SINGLE) {
|
||||
exporters = List.of(new Electrum());
|
||||
exporters = List.of(new Electrum(), new Specter());
|
||||
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
|
||||
exporters = List.of(new ColdcardMultisig(), new Electrum());
|
||||
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new Specter());
|
||||
} else {
|
||||
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
|
||||
}
|
||||
@@ -60,7 +58,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(360);
|
||||
dialogPane.setPrefHeight(480);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
stackPane.getChildren().add(anchorPane);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane();
|
||||
scrollPane.setPrefHeight(320);
|
||||
scrollPane.setPrefHeight(420);
|
||||
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
anchorPane.getChildren().add(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
@@ -39,13 +39,13 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
AnchorPane.setRightAnchor(scrollPane, 0.0);
|
||||
|
||||
Accordion importAccordion = new Accordion();
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig());
|
||||
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig());
|
||||
for(KeystoreFileImport importer : keystoreImporters) {
|
||||
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
|
||||
importAccordion.getPanes().add(importPane);
|
||||
}
|
||||
|
||||
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new Electrum());
|
||||
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new Specter());
|
||||
for(WalletImport importer : walletImporters) {
|
||||
FileWalletImportPane importPane = new FileWalletImportPane(importer);
|
||||
importAccordion.getPanes().add(importPane);
|
||||
@@ -55,7 +55,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(500);
|
||||
dialogPane.setPrefHeight(400);
|
||||
dialogPane.setPrefHeight(500);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
private final CustomPasswordField password;
|
||||
private final CustomPasswordField passwordConfirm;
|
||||
private final CheckBox backupExisting;
|
||||
private final CheckBox changePassword;
|
||||
|
||||
public WalletPasswordDialog(PasswordRequirement requirement) {
|
||||
this(null, requirement);
|
||||
@@ -31,6 +32,7 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
this.password = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
this.passwordConfirm = (CustomPasswordField)TextFields.createClearablePasswordField();
|
||||
this.backupExisting = new CheckBox("Backup existing wallet first");
|
||||
this.changePassword = new CheckBox("Change password");
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
setTitle("Wallet Password" + (walletName != null ? " - " + walletName : ""));
|
||||
@@ -55,6 +57,10 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
backupExisting.setSelected(true);
|
||||
}
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_SET) {
|
||||
content.getChildren().add(changePassword);
|
||||
}
|
||||
|
||||
dialogPane.setContent(content);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
@@ -70,12 +76,12 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> passwordConfirm.isVisible() && !password.getText().equals(passwordConfirm.getText()), password.textProperty(), passwordConfirm.textProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
if(requirement != PasswordRequirement.UPDATE_NEW) {
|
||||
if(requirement != PasswordRequirement.UPDATE_NEW && requirement != PasswordRequirement.UPDATE_CHANGE) {
|
||||
passwordConfirm.setVisible(false);
|
||||
passwordConfirm.setManaged(false);
|
||||
}
|
||||
|
||||
if(requirement == PasswordRequirement.UPDATE_NEW || requirement == PasswordRequirement.UPDATE_EMPTY) {
|
||||
if(requirement == PasswordRequirement.UPDATE_NEW || requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_CHANGE) {
|
||||
password.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(newValue.isEmpty()) {
|
||||
okButton.setText("No Password");
|
||||
@@ -100,11 +106,16 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
|
||||
return backupExisting.isSelected();
|
||||
}
|
||||
|
||||
public boolean isChangePassword() {
|
||||
return changePassword.isSelected();
|
||||
}
|
||||
|
||||
public enum PasswordRequirement {
|
||||
LOAD("Please enter the wallet password:", "Unlock"),
|
||||
UPDATE_NEW("Add a password to the wallet?\nLeave empty for none:", "No Password"),
|
||||
UPDATE_EMPTY("This wallet has no password.\nAdd a password to the wallet?\nLeave empty for none:", "No Password"),
|
||||
UPDATE_SET("Please re-enter the wallet password:", "Verify Password");
|
||||
UPDATE_SET("Please re-enter the wallet password:", "Verify Password"),
|
||||
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for none:", "No Password");
|
||||
|
||||
private final String description;
|
||||
private final String okButtonText;
|
||||
|
||||
@@ -15,8 +15,8 @@ import org.controlsfx.control.ToggleSwitch;
|
||||
public class WelcomeDialog extends Dialog<Mode> {
|
||||
private static final String[] ELECTRUM_SERVERS = new String[]{
|
||||
"ElectrumX (Recommended)", "https://github.com/spesmilo/electrumx",
|
||||
"electrs (Experimental)", "https://github.com/romanz/electrs",
|
||||
"esplora-electrs (Experimental)", "https://github.com/Blockstream/electrs"};
|
||||
"electrs", "https://github.com/romanz/electrs",
|
||||
"esplora-electrs", "https://github.com/Blockstream/electrs"};
|
||||
|
||||
private final HostServices hostServices;
|
||||
|
||||
|
||||
@@ -10,13 +10,15 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
|
||||
private final String serverBanner;
|
||||
private final int blockHeight;
|
||||
private final BlockHeader blockHeader;
|
||||
private final Double minimumRelayFeeRate;
|
||||
|
||||
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates) {
|
||||
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Double minimumRelayFeeRate) {
|
||||
super(targetBlockFeeRates);
|
||||
this.serverVersion = serverVersion;
|
||||
this.serverBanner = serverBanner;
|
||||
this.blockHeight = blockHeight;
|
||||
this.blockHeader = blockHeader;
|
||||
this.minimumRelayFeeRate = minimumRelayFeeRate;
|
||||
}
|
||||
|
||||
public List<String> getServerVersion() {
|
||||
@@ -34,4 +36,8 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
|
||||
public BlockHeader getBlockHeader() {
|
||||
return blockHeader;
|
||||
}
|
||||
|
||||
public Double getMinimumRelayFeeRate() {
|
||||
return minimumRelayFeeRate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.WalletTransaction;
|
||||
|
||||
public class ExcludeUtxoEvent {
|
||||
private final WalletTransaction walletTransaction;
|
||||
private final BlockTransactionHashIndex utxo;
|
||||
|
||||
public ExcludeUtxoEvent(WalletTransaction walletTransaction, BlockTransactionHashIndex utxo) {
|
||||
this.walletTransaction = walletTransaction;
|
||||
this.utxo = utxo;
|
||||
}
|
||||
|
||||
public WalletTransaction getWalletTransaction() {
|
||||
return walletTransaction;
|
||||
}
|
||||
|
||||
public BlockTransactionHashIndex getUtxo() {
|
||||
return utxo;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class TorStatusEvent {
|
||||
private final String status;
|
||||
|
||||
public TorStatusEvent(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class VersionCheckStatusEvent {
|
||||
private final boolean enabled;
|
||||
|
||||
public VersionCheckStatusEvent(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class VersionUpdatedEvent {
|
||||
private final String version;
|
||||
|
||||
public VersionUpdatedEvent(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
public class WalletHistoryStatusEvent {
|
||||
private final boolean loaded;
|
||||
private final String statusMessage;
|
||||
private final String errorMessage;
|
||||
|
||||
public WalletHistoryStatusEvent(boolean loaded) {
|
||||
this.loaded = loaded;
|
||||
this.statusMessage = null;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
public WalletHistoryStatusEvent(boolean loaded, String statusMessage) {
|
||||
this.loaded = false;
|
||||
this.statusMessage = statusMessage;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
public WalletHistoryStatusEvent(String errorMessage) {
|
||||
this.loaded = false;
|
||||
this.statusMessage = null;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public boolean isLoading() {
|
||||
return !loaded;
|
||||
}
|
||||
|
||||
public boolean isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public String getStatusMessage() {
|
||||
return statusMessage;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class FontAwesome5 extends GlyphFont {
|
||||
CHECK_CIRCLE('\uf058'),
|
||||
CIRCLE('\uf111'),
|
||||
COINS('\uf51e'),
|
||||
EXCHANGE_ALT('\uf362'),
|
||||
EXCLAMATION_CIRCLE('\uf06a'),
|
||||
EXCLAMATION_TRIANGLE('\uf071'),
|
||||
EXTERNAL_LINK_ALT('\uf35d'),
|
||||
@@ -34,12 +35,15 @@ public class FontAwesome5 extends GlyphFont {
|
||||
LOCK('\uf023'),
|
||||
LOCK_OPEN('\uf3c1'),
|
||||
PEN_FANCY('\uf5ac'),
|
||||
PLUS('\uf067'),
|
||||
QRCODE('\uf029'),
|
||||
QUESTION_CIRCLE('\uf059'),
|
||||
RANDOM('\uf074'),
|
||||
REPLY_ALL('\uf122'),
|
||||
SATELLITE_DISH('\uf7c0'),
|
||||
SD_CARD('\uf7c2'),
|
||||
SEARCH('\uf002'),
|
||||
TIMES_CIRCLE('\uf057'),
|
||||
TOOLS('\uf7d9'),
|
||||
UNDO('\uf0e2'),
|
||||
WALLET('\uf555');
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class CoboVaultMultisig extends ColdcardMultisig {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Cobo Vault Multisig";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.COBO_VAULT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
|
||||
keystore.setLabel("Cobo Vault");
|
||||
keystore.setWalletModel(getWalletModel());
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import file or QR created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
Wallet wallet = super.importWallet(inputStream, password);
|
||||
for(Keystore keystore : wallet.getKeystores()) {
|
||||
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Cobo Vault"));
|
||||
keystore.setWalletModel(WalletModel.COBO_VAULT);
|
||||
}
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return "Import file or QR created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Export file that can be read by your Cobo Vault using the Multisig Wallet > Import Multisig Wallet feature.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
|
||||
public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Cobo Vault";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription() {
|
||||
return "Import file or QR created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.COBO_VAULT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
CoboVaultSinglesigKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream), CoboVaultSinglesigKeystore.class);
|
||||
|
||||
if(coboKeystore.MasterFingerprint == null || coboKeystore.AccountKeyPath == null || coboKeystore.ExtPubKey == null) {
|
||||
throw new ImportException("Not a valid " + getName() + " keystore export");
|
||||
}
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(getName());
|
||||
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||
keystore.setWalletModel(WalletModel.COBO_VAULT);
|
||||
keystore.setKeyDerivation(new KeyDerivation(coboKeystore.MasterFingerprint.toLowerCase(), "m/" + coboKeystore.AccountKeyPath));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(coboKeystore.ExtPubKey));
|
||||
|
||||
ExtendedKey.Header header = ExtendedKey.Header.fromExtendedKey(coboKeystore.ExtPubKey);
|
||||
if(header.getDefaultScriptType() != scriptType) {
|
||||
throw new ImportException("This wallet's script type (" + scriptType + ") does not match the " + getName() + " script type (" + header.getDefaultScriptType() + ")");
|
||||
}
|
||||
|
||||
return keystore;
|
||||
} catch (Exception e) {
|
||||
throw new ImportException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return getKeystoreImportDescription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
//Use default of P2WPKH
|
||||
Keystore keystore = getKeystore(ScriptType.P2WPKH, inputStream, "");
|
||||
|
||||
Wallet wallet = new Wallet();
|
||||
wallet.setPolicyType(PolicyType.SINGLE);
|
||||
wallet.setScriptType(ScriptType.P2WPKH);
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
|
||||
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new ImportException("Imported Cobo Vault wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEncrypted(File file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static class CoboVaultSinglesigKeystore {
|
||||
public String ExtPubKey;
|
||||
public String MasterFingerprint;
|
||||
public String AccountKeyPath;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.common.io.CharStreams;
|
||||
import com.google.gson.Gson;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
@@ -19,8 +18,6 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class ColdcardMultisig implements WalletImport, KeystoreFileImport, WalletExport {
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Coldcard Multisig";
|
||||
@@ -40,7 +37,14 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||
keystore.setWalletModel(WalletModel.COLDCARD);
|
||||
|
||||
if(scriptType.equals(ScriptType.P2SH)) {
|
||||
if(cck.xpub != null && cck.path != null) {
|
||||
ExtendedKey.Header header = ExtendedKey.Header.fromExtendedKey(cck.xpub);
|
||||
if(header.getDefaultScriptType() != scriptType) {
|
||||
throw new ImportException("This wallet's script type (" + scriptType + ") does not match the " + getName() + " script type (" + header.getDefaultScriptType() + ")");
|
||||
}
|
||||
keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.path));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.xpub));
|
||||
} else if(scriptType.equals(ScriptType.P2SH)) {
|
||||
keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2sh_deriv));
|
||||
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2sh));
|
||||
} else if(scriptType.equals(ScriptType.P2SH_P2WSH)) {
|
||||
@@ -63,6 +67,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
public String p2wsh_p2sh;
|
||||
public String p2wsh_deriv;
|
||||
public String p2wsh;
|
||||
public String xpub;
|
||||
public String path;
|
||||
public String xfp;
|
||||
}
|
||||
|
||||
@@ -82,7 +88,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
wallet.setPolicyType(PolicyType.MULTI);
|
||||
|
||||
int threshold = 2;
|
||||
ScriptType scriptType = null;
|
||||
ScriptType scriptType = ScriptType.P2SH;
|
||||
String derivation = null;
|
||||
|
||||
try {
|
||||
@@ -153,7 +159,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
}
|
||||
|
||||
if(!wallet.getPolicyType().equals(PolicyType.MULTI)) {
|
||||
throw new ExportException("Coldcard multisig import requires a multisig wallet");
|
||||
throw new ExportException(getName() + " import requires a multisig wallet");
|
||||
}
|
||||
|
||||
boolean multipleDerivations = false;
|
||||
@@ -167,7 +173,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
|
||||
try {
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream));
|
||||
writer.append("# Coldcard Multisig setup file (created by Sparrow)\n");
|
||||
writer.append("# " + getName() + " setup file (created by Sparrow)\n");
|
||||
writer.append("#\n");
|
||||
writer.append("Name: ").append(wallet.getName()).append("\n");
|
||||
writer.append("Policy: ").append(Integer.toString(wallet.getDefaultPolicy().getNumSignaturesRequired())).append(" of ").append(Integer.toString(wallet.getKeystores().size())).append("\n");
|
||||
@@ -203,4 +209,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
|
||||
public boolean isEncrypted(File file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -16,7 +16,7 @@ import java.io.InputStreamReader;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Map;
|
||||
|
||||
public class ColdcardSinglesig implements KeystoreFileImport {
|
||||
public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Coldcard";
|
||||
@@ -37,6 +37,11 @@ public class ColdcardSinglesig implements KeystoreFileImport {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
|
||||
try {
|
||||
@@ -59,7 +64,7 @@ public class ColdcardSinglesig implements KeystoreFileImport {
|
||||
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").toUpperCase());
|
||||
if(ckScriptType.equals(scriptType)) {
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel("Coldcard");
|
||||
keystore.setLabel(getName());
|
||||
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
|
||||
keystore.setWalletModel(WalletModel.COLDCARD);
|
||||
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, ck.deriv));
|
||||
@@ -77,6 +82,31 @@ public class ColdcardSinglesig implements KeystoreFileImport {
|
||||
throw new ImportException("Correct derivation not found for script type: " + scriptType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return getKeystoreImportDescription();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
//Use default of P2WPKH
|
||||
Keystore keystore = getKeystore(ScriptType.P2WPKH, inputStream, "");
|
||||
|
||||
Wallet wallet = new Wallet();
|
||||
wallet.setPolicyType(PolicyType.SINGLE);
|
||||
wallet.setScriptType(ScriptType.P2WPKH);
|
||||
wallet.getKeystores().add(keystore);
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
|
||||
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new ImportException("Imported Coldcard wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
private static class ColdcardKeystore {
|
||||
public String deriv;
|
||||
public String name;
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
|
||||
import com.google.gson.*;
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.sparrow.Mode;
|
||||
import com.sparrowwallet.sparrow.Theme;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -23,6 +24,8 @@ public class Config {
|
||||
private boolean groupByAddress = true;
|
||||
private boolean includeMempoolChange = true;
|
||||
private boolean notifyNewTransactions = true;
|
||||
private boolean checkNewVersions = true;
|
||||
private Theme theme;
|
||||
private List<File> recentWalletFiles;
|
||||
private Integer keyDerivationPeriod;
|
||||
private File hwi;
|
||||
@@ -41,7 +44,12 @@ public class Config {
|
||||
}
|
||||
|
||||
private static File getConfigFile() {
|
||||
return new File(Storage.getSparrowDir(), CONFIG_FILENAME);
|
||||
File sparrowDir = Storage.getSparrowDir();
|
||||
if(!sparrowDir.exists()) {
|
||||
sparrowDir.mkdirs();
|
||||
}
|
||||
|
||||
return new File(sparrowDir, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
private static Config load() {
|
||||
@@ -135,6 +143,24 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isCheckNewVersions() {
|
||||
return checkNewVersions;
|
||||
}
|
||||
|
||||
public void setCheckNewVersions(boolean checkNewVersions) {
|
||||
this.checkNewVersions = checkNewVersions;
|
||||
flush();
|
||||
}
|
||||
|
||||
public Theme getTheme() {
|
||||
return theme;
|
||||
}
|
||||
|
||||
public void setTheme(Theme theme) {
|
||||
this.theme = theme;
|
||||
flush();
|
||||
}
|
||||
|
||||
public List<File> getRecentWalletFiles() {
|
||||
return recentWalletFiles;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.sparrowwallet.drongo.crypto.*;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
|
||||
import java.io.*;
|
||||
@@ -81,6 +83,38 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
}
|
||||
ew.keystores.put(key, ek);
|
||||
}
|
||||
|
||||
if(key.equals("labels")) {
|
||||
JsonObject jsonObject = (JsonObject)map.get(key);
|
||||
for(String labelKey : jsonObject.keySet()) {
|
||||
ew.labels.put(labelKey, jsonObject.get(labelKey).getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
if(key.equals("verified_tx3")) {
|
||||
JsonObject jsonObject = (JsonObject)map.get(key);
|
||||
for(String txKey : jsonObject.keySet()) {
|
||||
Sha256Hash txHash = Sha256Hash.wrap(txKey);
|
||||
JsonArray array = jsonObject.getAsJsonArray(txKey);
|
||||
if(array != null && array.size() > 3) {
|
||||
int height = array.get(0).getAsInt();
|
||||
Date date = new Date(array.get(1).getAsLong() * 1000);
|
||||
long fee = array.get(2).getAsLong();
|
||||
Sha256Hash blockHash = Sha256Hash.wrap(array.get(3).getAsString());
|
||||
|
||||
JsonObject transactions = (JsonObject)map.get("transactions");
|
||||
if(transactions != null) {
|
||||
String txhex = transactions.get(txKey).getAsString();
|
||||
if(txhex != null) {
|
||||
Transaction transaction = new Transaction(Utils.hexToBytes(txhex));
|
||||
|
||||
BlockTransaction blockTransaction = new BlockTransaction(txHash, height, date, fee, transaction, blockHash);
|
||||
ew.transactions.put(txHash, blockTransaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Wallet wallet = new Wallet();
|
||||
@@ -104,6 +138,9 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
if(keystore.getWalletModel() == null) {
|
||||
throw new ImportException("Wallet has keystore of unknown hardware wallet type \"" + ek.hw_type + "\".");
|
||||
}
|
||||
if(keystore.getWalletModel().equals(WalletModel.TREZOR_1)) {
|
||||
keystore.setWalletModel(WalletModel.TREZOR_T);
|
||||
}
|
||||
} else if("bip32".equals(ek.type)) {
|
||||
if(ek.xprv != null && ek.seed == null) {
|
||||
throw new ImportException("Electrum does not support exporting BIP39 derived seeds, as it does not store the mnemonic words. Only seeds created with its native Electrum Seed Version System are exportable. " +
|
||||
@@ -150,8 +187,24 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
throw new ImportException("Unknown Electrum wallet type of " + ew.wallet_type);
|
||||
}
|
||||
|
||||
if(!wallet.isValid()) {
|
||||
throw new IllegalStateException("Electrum wallet is in an inconsistent state.");
|
||||
for(String key : ew.labels.keySet()) {
|
||||
try {
|
||||
Sha256Hash txHash = Sha256Hash.wrap(key);
|
||||
BlockTransaction blockTransaction = ew.transactions.get(txHash);
|
||||
if(blockTransaction != null) {
|
||||
blockTransaction.setLabel(ew.labels.get(key));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//not a tx, probably an address
|
||||
}
|
||||
}
|
||||
|
||||
wallet.updateTransactions(ew.transactions);
|
||||
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new IllegalStateException("Imported Electrum wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
@@ -269,6 +322,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
return (FileType.BINARY.equals(IOUtils.getFileType(file)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Export this wallet as an Electrum wallet file.";
|
||||
@@ -279,6 +337,8 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
|
||||
public String wallet_type;
|
||||
public String seed_type;
|
||||
public Boolean use_encryption;
|
||||
public Map<String, String> labels = new LinkedHashMap<>();
|
||||
public Map<Sha256Hash, BlockTransaction> transactions = new HashMap<>();
|
||||
}
|
||||
|
||||
public static class ElectrumKeystore {
|
||||
|
||||
@@ -4,4 +4,5 @@ import java.io.File;
|
||||
|
||||
public interface FileImport extends Import {
|
||||
boolean isEncrypted(File file);
|
||||
boolean isScannable();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
|
||||
import com.google.common.io.ByteStreams;
|
||||
import com.google.common.io.CharStreams;
|
||||
import com.google.gson.*;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
@@ -23,13 +24,12 @@ import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public class Hwi {
|
||||
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
|
||||
private static final String TEMP_FILE_PREFIX = "hwi-1.1.2-";
|
||||
private static final String TEMP_FILE_PREFIX = "hwi-1.2.0-";
|
||||
|
||||
private static boolean isPromptActive = false;
|
||||
|
||||
@@ -73,7 +73,7 @@ public class Hwi {
|
||||
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
|
||||
try {
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().equals(WalletModel.TREZOR_1)) {
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.GET_XPUB, derivationPath));
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.GET_XPUB, derivationPath));
|
||||
@@ -84,7 +84,7 @@ public class Hwi {
|
||||
if(result.get("xpub") != null) {
|
||||
return result.get("xpub").getAsString();
|
||||
} else {
|
||||
throw new ImportException("Could not retrieve xpub");
|
||||
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
|
||||
}
|
||||
} catch(IOException e) {
|
||||
throw new ImportException(e);
|
||||
@@ -106,7 +106,7 @@ public class Hwi {
|
||||
|
||||
isPromptActive = false;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().equals(WalletModel.TREZOR_1)) {
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
|
||||
@@ -132,7 +132,7 @@ public class Hwi {
|
||||
try {
|
||||
isPromptActive = false;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().equals(WalletModel.TREZOR_1)) {
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_MESSAGE, message, derivationPath));
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.SIGN_MESSAGE, message, derivationPath));
|
||||
@@ -160,7 +160,7 @@ public class Hwi {
|
||||
|
||||
isPromptActive = true;
|
||||
String output;
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().equals(WalletModel.TREZOR_1)) {
|
||||
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
|
||||
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_TX, psbtBase64));
|
||||
} else {
|
||||
output = execute(getDeviceCommand(device, Command.SIGN_TX, psbtBase64));
|
||||
@@ -200,7 +200,7 @@ public class Hwi {
|
||||
File hwiExecutable = Config.get().getHwi();
|
||||
if(hwiExecutable != null && hwiExecutable.exists()) {
|
||||
if(command.isTestFirst() && (!hwiExecutable.getAbsolutePath().contains(TEMP_FILE_PREFIX) || !testHwi(hwiExecutable))) {
|
||||
if(Platform.getCurrent().getPlatformId().toLowerCase().equals("mac")) {
|
||||
if(Platform.getCurrent() == Platform.OSX) {
|
||||
deleteDirectory(hwiExecutable.getParentFile());
|
||||
} else {
|
||||
hwiExecutable.delete();
|
||||
@@ -218,7 +218,7 @@ public class Hwi {
|
||||
//The check will still happen on first invocation, but will not thereafter
|
||||
//See https://github.com/bitcoin-core/HWI/issues/327 for details
|
||||
if(platform == Platform.OSX) {
|
||||
InputStream inputStream = Hwi.class.getResourceAsStream("/external/mac/hwi-1.1.2-mac-amd64-signed.zip");
|
||||
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/hwi-1.2.0-mac-amd64-signed.zip");
|
||||
Path tempHwiDirPath = Files.createTempDirectory(TEMP_FILE_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
|
||||
File tempHwiDir = tempHwiDirPath.toFile();
|
||||
//tempHwiDir.deleteOnExit();
|
||||
@@ -249,10 +249,10 @@ public class Hwi {
|
||||
InputStream inputStream;
|
||||
Path tempExecPath;
|
||||
if(platform == Platform.WINDOWS) {
|
||||
inputStream = Hwi.class.getResourceAsStream("/external/windows/hwi.exe");
|
||||
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
|
||||
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null);
|
||||
} else {
|
||||
inputStream = Hwi.class.getResourceAsStream("/external/linux/hwi");
|
||||
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
|
||||
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
|
||||
}
|
||||
|
||||
@@ -323,17 +323,27 @@ public class Hwi {
|
||||
}
|
||||
|
||||
private List<String> getDeviceCommand(Device device, Command command) throws IOException {
|
||||
return List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString());
|
||||
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--testnet");
|
||||
}
|
||||
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()));
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--testnet");
|
||||
}
|
||||
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()));
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
elements.add(elements.size() - 1, "--testnet");
|
||||
}
|
||||
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
|
||||
return elements;
|
||||
}
|
||||
@@ -525,7 +535,17 @@ public class Hwi {
|
||||
private static class DeviceModelDeserializer implements JsonDeserializer<WalletModel> {
|
||||
@Override
|
||||
public WalletModel deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
||||
return WalletModel.valueOf(json.getAsJsonPrimitive().getAsString().toUpperCase());
|
||||
String modelStr = json.getAsJsonPrimitive().getAsString();
|
||||
try {
|
||||
return WalletModel.valueOf(modelStr.toUpperCase());
|
||||
} catch(Exception e) {
|
||||
for(WalletModel model : WalletModel.values()) {
|
||||
if(modelStr.startsWith(model.getType())) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Could not determine wallet model for " + modelStr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class Specter implements WalletImport, WalletExport {
|
||||
@Override
|
||||
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
|
||||
try {
|
||||
SpecterWallet specterWallet = new SpecterWallet();
|
||||
specterWallet.label = wallet.getName();
|
||||
specterWallet.blockheight = wallet.getStoredBlockHeight();
|
||||
specterWallet.descriptor = OutputDescriptor.getOutputDescriptor(wallet).toString(true);
|
||||
|
||||
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
|
||||
String json = gson.toJson(specterWallet);
|
||||
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.flush();
|
||||
outputStream.close();
|
||||
} catch(Exception e) {
|
||||
throw new ExportException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletExportDescription() {
|
||||
return "Export a Specter wallet that can be read by Specter Desktop using Add new wallet > Import from wallet software";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getExportFileExtension() {
|
||||
return "json";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return "Import a Specter wallet created by using the Wallets > Settings > Export to Wallet Software in Specter Desktop";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream), SpecterWallet.class);
|
||||
|
||||
if(specterWallet.descriptor != null) {
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(specterWallet.descriptor);
|
||||
Wallet wallet = outputDescriptor.toWallet();
|
||||
wallet.setName(specterWallet.label);
|
||||
|
||||
try {
|
||||
wallet.checkWallet();
|
||||
} catch(InvalidWalletException e) {
|
||||
throw new ImportException("Imported Specter wallet was invalid: " + e.getMessage());
|
||||
}
|
||||
|
||||
return wallet;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
}
|
||||
|
||||
throw new ImportException("File was not a valid Specter wallet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEncrypted(File file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Specter";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.SPECTER;
|
||||
}
|
||||
|
||||
public static class SpecterWallet {
|
||||
public String label;
|
||||
public Integer blockheight;
|
||||
public String descriptor;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
|
||||
import com.google.common.io.Files;
|
||||
import com.google.gson.*;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.crypto.*;
|
||||
@@ -12,6 +13,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.MnemonicException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.MainApp;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import org.controlsfx.tools.Platform;
|
||||
@@ -79,15 +81,16 @@ public class Storage {
|
||||
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
|
||||
}
|
||||
|
||||
public Wallet loadWallet() throws IOException, MnemonicException {
|
||||
public Wallet loadWallet() throws IOException {
|
||||
Reader reader = new FileReader(walletFile);
|
||||
Wallet wallet = gson.fromJson(reader, Wallet.class);
|
||||
reader.close();
|
||||
|
||||
encryptionPubKey = NO_PASSWORD_KEY;
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public WalletAndKey loadWallet(CharSequence password) throws IOException, MnemonicException, StorageException {
|
||||
public WalletAndKey loadWallet(CharSequence password) throws IOException, StorageException {
|
||||
InputStream fileStream = new FileInputStream(walletFile);
|
||||
ECKey encryptionKey = getEncryptionKey(password, fileStream);
|
||||
|
||||
@@ -219,12 +222,16 @@ public class Storage {
|
||||
if(read != BINARY_HEADER_LENGTH) {
|
||||
throw new StorageException("Not a Sparrow wallet - invalid header");
|
||||
}
|
||||
byte[] decodedHeader = Base64.getDecoder().decode(header);
|
||||
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
|
||||
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
|
||||
throw new StorageException("Not a Sparrow wallet - invalid magic");
|
||||
try {
|
||||
byte[] decodedHeader = Base64.getDecoder().decode(header);
|
||||
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
|
||||
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
|
||||
throw new StorageException("Not a Sparrow wallet - invalid magic");
|
||||
}
|
||||
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
|
||||
} catch(IllegalArgumentException e) {
|
||||
throw new StorageException("Not a Sparrow wallet - invalid header");
|
||||
}
|
||||
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
|
||||
} else {
|
||||
SecureRandom secureRandom = new SecureRandom();
|
||||
secureRandom.nextBytes(salt);
|
||||
@@ -247,6 +254,19 @@ public class Storage {
|
||||
return (encrypted.exists() || unencrypted.exists());
|
||||
}
|
||||
|
||||
public static File getExistingWallet(String walletName) {
|
||||
File encrypted = new File(getWalletsDir(), walletName);
|
||||
File unencrypted = new File(getWalletsDir(), walletName + ".json");
|
||||
|
||||
if(encrypted.exists()) {
|
||||
return encrypted;
|
||||
} else if(unencrypted.exists()) {
|
||||
return unencrypted;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static File getWalletFile(String walletName) {
|
||||
//TODO: Check for existing file
|
||||
return new File(getWalletsDir(), walletName);
|
||||
@@ -271,6 +291,18 @@ public class Storage {
|
||||
}
|
||||
|
||||
static File getSparrowDir() {
|
||||
if(Network.get() != Network.MAINNET) {
|
||||
return new File(getSparrowHome(), Network.get().getName());
|
||||
}
|
||||
|
||||
return getSparrowHome();
|
||||
}
|
||||
|
||||
public static File getSparrowHome() {
|
||||
if(System.getProperty(MainApp.APP_HOME_PROPERTY) != null) {
|
||||
return new File(System.getProperty(MainApp.APP_HOME_PROPERTY));
|
||||
}
|
||||
|
||||
if(Platform.getCurrent() == Platform.WINDOWS) {
|
||||
return new File(getHomeDir(), WINDOWS_SPARROW_DIR);
|
||||
}
|
||||
@@ -446,11 +478,9 @@ public class Storage {
|
||||
protected Task<WalletAndKey> createTask() {
|
||||
return new Task<>() {
|
||||
protected WalletAndKey call() throws IOException, StorageException, MnemonicException {
|
||||
try {
|
||||
return storage.loadWallet(password);
|
||||
} finally {
|
||||
password.clear();
|
||||
}
|
||||
WalletAndKey walletAndKey = storage.loadWallet(password);
|
||||
password.clear();
|
||||
return walletAndKey;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,9 +16,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());
|
||||
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig());
|
||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
||||
importers = List.of(new ColdcardMultisig());
|
||||
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig());
|
||||
}
|
||||
|
||||
for(KeystoreImport importer : importers) {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package com.sparrowwallet.sparrow.keystoreimport;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.DialogPane;
|
||||
import javafx.scene.control.*;
|
||||
import org.controlsfx.tools.Borders;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -36,17 +36,18 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
|
||||
|
||||
try {
|
||||
FXMLLoader ksiLoader = new FXMLLoader(AppController.class.getResource("keystoreimport/keystoreimport.fxml"));
|
||||
dialogPane.setContent(Borders.wrap(ksiLoader.load()).lineBorder().outerPadding(0).innerPadding(0).buildAll());
|
||||
dialogPane.setContent(Borders.wrap(ksiLoader.load()).emptyBorder().buildAll());
|
||||
keystoreImportController = ksiLoader.getController();
|
||||
keystoreImportController.initializeView(wallet);
|
||||
keystoreImportController.selectSource(initialSource);
|
||||
|
||||
final ButtonType watchOnlyButtonType = new javafx.scene.control.ButtonType(Network.get().getXpubHeader().getDisplayName() + " / Watch Only Wallet", ButtonBar.ButtonData.LEFT);
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.getButtonTypes().addAll(watchOnlyButtonType, cancelButtonType);
|
||||
dialogPane.setPrefWidth(650);
|
||||
dialogPane.setPrefHeight(620);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? keystore : null);
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? getWatchOnlyKeystore() : null);
|
||||
} catch(IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
@@ -56,6 +57,14 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
|
||||
return List.of(KeystoreSource.HW_USB, KeystoreSource.HW_AIRGAPPED, KeystoreSource.SW_SEED);
|
||||
}
|
||||
|
||||
private Keystore getWatchOnlyKeystore() {
|
||||
this.keystore = new Keystore();
|
||||
keystore.setSource(KeystoreSource.SW_WATCH);
|
||||
keystore.setWalletModel(WalletModel.SPARROW);
|
||||
keystore.setKeyDerivation(new KeyDerivation("",""));
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void keystoreImported(KeystoreImportEvent event) {
|
||||
this.keystore = event.getKeystore();
|
||||
|
||||
@@ -6,22 +6,30 @@ 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;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
private static final Logger log = LoggerFactory.getLogger(BatchedElectrumServerRpc.class);
|
||||
private static final int MAX_RETRIES = 3;
|
||||
private static final int RETRY_DELAY = 0;
|
||||
|
||||
private final AtomicLong idCounter = new AtomicLong();
|
||||
|
||||
@Override
|
||||
public void ping(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
client.createRequest().method("server.ping").id(1).executeNullable();
|
||||
} catch(JsonRpcException e) {
|
||||
new RetryLogic<>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error pinging server", e);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +38,9 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAsList(String.class).method("server.version").id(1).param("client_name", clientName).param("protocol_version", supportedVersions).execute();
|
||||
} catch(JsonRpcException e) {
|
||||
return new RetryLogic<List<String>>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
@@ -40,8 +49,9 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public String getServerBanner(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAs(String.class).method("server.banner").id(1).execute();
|
||||
} catch(JsonRpcException e) {
|
||||
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
@@ -50,8 +60,9 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public BlockHeaderTip subscribeBlockHeaders(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(1).execute();
|
||||
} catch(JsonRpcException e) {
|
||||
return new RetryLogic<BlockHeaderTip>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
@@ -61,13 +72,14 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Map<String, String> pathScriptHashes, boolean failOnError) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Loading transactions"));
|
||||
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
batchRequest.add(path, "blockchain.scripthash.get_history", pathScriptHashes.get(path));
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
return new RetryLogic<Map<String, ScriptHashTx[]>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
} catch (JsonRpcBatchException e) {
|
||||
if(failOnError) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + e.getErrors().keySet(), e);
|
||||
@@ -79,6 +91,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + pathScriptHashes.keySet(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +107,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
} catch (JsonRpcBatchException e) {
|
||||
return new RetryLogic<Map<String, ScriptHashTx[]>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
} catch(JsonRpcBatchException e) {
|
||||
if(failOnError) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + e.getErrors().keySet(), e);
|
||||
}
|
||||
@@ -105,6 +119,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve references for paths: " + pathScriptHashes.keySet(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +128,19 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public Map<String, String> subscribeScriptHashes(Transport transport, Map<String, String> pathScriptHashes) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Finding transactions"));
|
||||
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
batchRequest.add(path, "blockchain.scripthash.subscribe", pathScriptHashes.get(path));
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
return new RetryLogic<Map<String, String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(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 for updates for paths: " + e.getErrors().keySet(), e);
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to subscribe for updates for paths: " + pathScriptHashes.keySet(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,14 +149,18 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public Map<Integer, String> getBlockHeaders(Transport transport, Set<Integer> blockHeights) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BatchRequestBuilder<Integer, String> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(String.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving blocks"));
|
||||
|
||||
for(Integer height : blockHeights) {
|
||||
batchRequest.add(height, "blockchain.block.header", height);
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
} catch (JsonRpcBatchException e) {
|
||||
return new RetryLogic<Map<Integer, String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
} catch(JsonRpcBatchException e) {
|
||||
return (Map<Integer, String>)e.getSuccesses();
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to block headers for block heights: " + blockHeights, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,13 +169,15 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public Map<String, String> getTransactions(Transport transport, Set<String> txids) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving transactions"));
|
||||
|
||||
for(String txid : txids) {
|
||||
batchRequest.add(txid, "blockchain.transaction.get", txid);
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
} catch (JsonRpcBatchException e) {
|
||||
return new RetryLogic<Map<String, String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
} catch(JsonRpcBatchException e) {
|
||||
Map<String, String> result = (Map<String, String>)e.getSuccesses();
|
||||
|
||||
String strErrorTx = Sha256Hash.ZERO_HASH.toString();
|
||||
@@ -162,6 +187,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve transactions for txids: " + txids, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,10 +202,12 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
} catch (JsonRpcBatchException e) {
|
||||
return new RetryLogic<Map<String, VerboseTransaction>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
} catch(JsonRpcBatchException e) {
|
||||
log.warn("Some errors retrieving transactions: " + e.getErrors());
|
||||
return (Map<String, VerboseTransaction>)e.getSuccesses();
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve verbose transactions for txids: " + txids, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,9 +220,22 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
|
||||
try {
|
||||
return batchRequest.execute();
|
||||
return new RetryLogic<Map<Integer, Double>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
|
||||
} catch(JsonRpcBatchException e) {
|
||||
throw new ElectrumServerRpcException("Error getting fee estimates", e);
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error getting fee estimates for target blocks: " + targetBlocks, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double getMinimumRelayFee(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<Double>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,9 +243,12 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
public String broadcastTransaction(Transport transport, String txHex) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(1).param("raw_tx", txHex).execute();
|
||||
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).param("raw_tx", txHex).execute());
|
||||
} catch(JsonRpcException e) {
|
||||
throw new ElectrumServerRpcException(e.getErrorMessage().getMessage(), e);
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error broadcasting transaction", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
@@ -8,8 +10,11 @@ import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.event.ConnectionEvent;
|
||||
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
|
||||
import com.sparrowwallet.sparrow.event.TorStatusEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.wallet.SendController;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
@@ -29,7 +34,7 @@ public class ElectrumServer {
|
||||
|
||||
private static Transport transport;
|
||||
|
||||
private static final Map<String, String> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
|
||||
private static final Map<String, Set<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
|
||||
|
||||
@@ -130,37 +135,82 @@ public class ElectrumServer {
|
||||
return receiveTransactionMap;
|
||||
}
|
||||
|
||||
public Map<WalletNode, Set<BlockTransactionHash>> getHistory(Wallet wallet, Collection<WalletNode> nodes) throws ServerException {
|
||||
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
|
||||
subscribeWalletNodes(wallet, nodes, nodeTransactionMap, 0);
|
||||
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, 0);
|
||||
Set<BlockTransactionHash> newReferences = nodeTransactionMap.values().stream().flatMap(Collection::stream).filter(ref -> !wallet.getTransactions().containsKey(ref.getHash())).collect(Collectors.toSet());
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
|
||||
if(!newReferences.isEmpty()) {
|
||||
//Look for additional nodes to fetch history for by considering the inputs and outputs of new transactions found
|
||||
log.debug("Found new transactions: " + newReferences);
|
||||
Set<WalletNode> additionalNodes = new HashSet<>();
|
||||
Map<String, WalletNode> walletScriptHashes = getAllScriptHashes(wallet);
|
||||
for(BlockTransactionHash reference : newReferences) {
|
||||
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
|
||||
for(TransactionOutput txOutput : blockTransaction.getTransaction().getOutputs()) {
|
||||
WalletNode node = walletScriptHashes.get(getScriptHash(txOutput));
|
||||
if(node != null && !nodes.contains(node)) {
|
||||
additionalNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
for(TransactionInput txInput : blockTransaction.getTransaction().getInputs()) {
|
||||
BlockTransaction inputBlockTransaction = wallet.getTransactions().get(txInput.getOutpoint().getHash());
|
||||
if(inputBlockTransaction != null) {
|
||||
TransactionOutput txOutput = inputBlockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex());
|
||||
WalletNode node = walletScriptHashes.get(getScriptHash(txOutput));
|
||||
if(node != null && !nodes.contains(node)) {
|
||||
additionalNodes.add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!additionalNodes.isEmpty()) {
|
||||
log.debug("Found additional nodes: " + additionalNodes);
|
||||
subscribeWalletNodes(wallet, additionalNodes, nodeTransactionMap, 0);
|
||||
getReferences(wallet, additionalNodes, nodeTransactionMap, 0);
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeTransactionMap;
|
||||
}
|
||||
|
||||
public void getHistory(Wallet wallet, KeyPurpose keyPurpose, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
|
||||
WalletNode purposeNode = wallet.getNode(keyPurpose);
|
||||
getHistory(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
|
||||
//Subscribe to all existing address WalletNodes and add them to nodeTransactionMap as keys to empty sets if they have history
|
||||
subscribeWalletNodes(wallet, purposeNode.getChildren(), 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
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
|
||||
//Because node children are added sequentially in WalletNode.fillToIndex, we can simply look at the number of children to determine the highest filled index
|
||||
int historySize = purposeNode.getChildren().size();
|
||||
//The gap limit size takes the highest used index in the retrieved history and adds the gap limit (plus one to be comparable to the number of children since index is zero based)
|
||||
int gapLimitSize = getGapLimitSize(nodeTransactionMap);
|
||||
int gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
|
||||
while(historySize < gapLimitSize) {
|
||||
purposeNode.fillToIndex(gapLimitSize - 1);
|
||||
getHistory(wallet, purposeNode.getChildren(), nodeTransactionMap, historySize);
|
||||
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, historySize);
|
||||
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, historySize);
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
historySize = purposeNode.getChildren().size();
|
||||
gapLimitSize = getGapLimitSize(nodeTransactionMap);
|
||||
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
|
||||
}
|
||||
|
||||
//Set the remaining WalletNode keys in nodeTransactionMap to empty sets to indicate no history
|
||||
purposeNode.getChildren().stream().filter(node -> !nodeTransactionMap.containsKey(node)).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
|
||||
}
|
||||
|
||||
private int getGapLimitSize(Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
|
||||
int highestIndex = nodeTransactionMap.entrySet().stream().filter(entry -> !entry.getValue().isEmpty()).map(entry -> entry.getKey().getIndex()).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
|
||||
return highestIndex + Wallet.DEFAULT_LOOKAHEAD + 1;
|
||||
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;
|
||||
}
|
||||
|
||||
public void getHistory(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
|
||||
getReferences(wallet, "blockchain.scripthash.get_history", nodes, nodeTransactionMap, startIndex);
|
||||
subscribeWalletNodes(wallet, nodes, startIndex);
|
||||
}
|
||||
|
||||
public void getMempool(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
|
||||
getReferences(wallet, "blockchain.scripthash.get_mempool", nodes, nodeTransactionMap, startIndex);
|
||||
}
|
||||
|
||||
public void getReferences(Wallet wallet, String method, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
|
||||
public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
|
||||
try {
|
||||
Map<String, String> pathScriptHashes = new LinkedHashMap<>(nodes.size());
|
||||
for(WalletNode node : nodes) {
|
||||
@@ -169,6 +219,10 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
if(pathScriptHashes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Even if we have some successes, failure to retrieve all references will result in an incomplete wallet history. Don't proceed if that's the case.
|
||||
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), pathScriptHashes, true);
|
||||
|
||||
@@ -179,7 +233,19 @@ public class ElectrumServer {
|
||||
if(optionalNode.isPresent()) {
|
||||
WalletNode node = optionalNode.get();
|
||||
|
||||
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash).collect(Collectors.toCollection(TreeSet::new));
|
||||
//Some servers can return the same tx as multiple ScriptHashTx entries with different heights. Take the highest height only
|
||||
Set<BlockTransactionHash> references = Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash)
|
||||
.collect(TreeSet::new, (set, ref) -> {
|
||||
Optional<BlockTransactionHash> optExisting = set.stream().filter(prev -> prev.getHash().equals(ref.getHash())).findFirst();
|
||||
if(optExisting.isPresent()) {
|
||||
if(optExisting.get().getHeight() < ref.getHeight()) {
|
||||
set.remove(optExisting.get());
|
||||
set.add(ref);
|
||||
}
|
||||
} else {
|
||||
set.add(ref);
|
||||
}
|
||||
}, TreeSet::addAll);
|
||||
Set<BlockTransactionHash> existingReferences = nodeTransactionMap.get(node);
|
||||
|
||||
if(existingReferences == null) {
|
||||
@@ -207,14 +273,22 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
public void subscribeWalletNodes(Wallet wallet, Collection<WalletNode> nodes, int startIndex) throws ServerException {
|
||||
public void subscribeWalletNodes(Wallet wallet, Collection<WalletNode> nodes, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, int startIndex) throws ServerException {
|
||||
try {
|
||||
Set<String> scriptHashes = new HashSet<>();
|
||||
Map<String, String> pathScriptHashes = new LinkedHashMap<>();
|
||||
for(WalletNode node : nodes) {
|
||||
if(node.getIndex() >= startIndex) {
|
||||
if(node == null) {
|
||||
log.error("Null node for wallet " + wallet.getName() + " subscribing nodes " + nodes + " startIndex " + startIndex, new Throwable());
|
||||
}
|
||||
|
||||
if(node != null && node.getIndex() >= startIndex) {
|
||||
String scriptHash = getScriptHash(wallet, node);
|
||||
if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
|
||||
if(getSubscribedScriptHashStatus(scriptHash) != null) {
|
||||
//Already subscribed, but still need to fetch history from a used node
|
||||
nodeTransactionMap.put(node, new TreeSet<>());
|
||||
} else if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
|
||||
//Unique script hash we are not yet subscribed to
|
||||
pathScriptHashes.put(node.getDerivationPath(), scriptHash);
|
||||
}
|
||||
}
|
||||
@@ -232,7 +306,15 @@ public class ElectrumServer {
|
||||
Optional<WalletNode> optionalNode = nodes.stream().filter(n -> n.getDerivationPath().equals(path)).findFirst();
|
||||
if(optionalNode.isPresent()) {
|
||||
WalletNode node = optionalNode.get();
|
||||
subscribedScriptHashes.put(getScriptHash(wallet, node), status);
|
||||
String scriptHash = getScriptHash(wallet, node);
|
||||
|
||||
//Check if there is history for this script hash
|
||||
if(status != null) {
|
||||
//Set the value for this node to be an empty set to mark it as requiring a get_history RPC call for this wallet
|
||||
nodeTransactionMap.put(node, new TreeSet<>());
|
||||
}
|
||||
|
||||
updateSubscribedScriptHashStatus(scriptHash, status);
|
||||
}
|
||||
}
|
||||
} catch (ElectrumServerRpcException e) {
|
||||
@@ -292,6 +374,14 @@ public class ElectrumServer {
|
||||
references.addAll(nodeReferences);
|
||||
}
|
||||
|
||||
for(Iterator<BlockTransactionHash> iter = references.iterator(); iter.hasNext(); ) {
|
||||
BlockTransactionHash reference = iter.next();
|
||||
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
|
||||
if(blockTransaction != null && reference.getHeight() == blockTransaction.getHeight()) {
|
||||
iter.remove();
|
||||
}
|
||||
}
|
||||
|
||||
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
|
||||
if(!references.isEmpty()) {
|
||||
Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(references);
|
||||
@@ -362,7 +452,14 @@ public class ElectrumServer {
|
||||
}
|
||||
|
||||
byte[] rawtx = Utils.hexToBytes(strRawTx);
|
||||
Transaction transaction = new Transaction(rawtx);
|
||||
Transaction transaction;
|
||||
|
||||
try {
|
||||
transaction = new Transaction(rawtx);
|
||||
} catch(ProtocolException e) {
|
||||
log.error("Could not parse tx: " + strRawTx);
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<BlockTransactionHash> optionalReference = references.stream().filter(reference -> reference.getHash().equals(hash)).findFirst();
|
||||
if(optionalReference.isEmpty()) {
|
||||
@@ -413,7 +510,9 @@ public class ElectrumServer {
|
||||
Set<BlockTransactionHash> history = nodeTransactionMap.get(node);
|
||||
for(BlockTransactionHash reference : history) {
|
||||
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
|
||||
if(blockTransaction == null || blockTransaction.equals(UNFETCHABLE_BLOCK_TRANSACTION)) {
|
||||
if(blockTransaction == null) {
|
||||
throw new IllegalStateException("Did not retrieve transaction for hash " + reference.getHashAsString());
|
||||
} else if(blockTransaction.equals(UNFETCHABLE_BLOCK_TRANSACTION)) {
|
||||
throw new IllegalStateException("Could not retrieve transaction for hash " + reference.getHashAsString());
|
||||
}
|
||||
Transaction transaction = blockTransaction.getTransaction();
|
||||
@@ -501,7 +600,8 @@ public class ElectrumServer {
|
||||
|
||||
Map<Integer, Double> targetBlocksFeeRatesSats = new TreeMap<>();
|
||||
for(Integer target : targetBlocksFeeRatesBtcKb.keySet()) {
|
||||
targetBlocksFeeRatesSats.put(target, targetBlocksFeeRatesBtcKb.get(target) * Transaction.SATOSHIS_PER_BITCOIN / 1024);
|
||||
long minFeeRateSatsKb = (long)(targetBlocksFeeRatesBtcKb.get(target) * Transaction.SATOSHIS_PER_BITCOIN);
|
||||
targetBlocksFeeRatesSats.put(target, minFeeRateSatsKb / 1000d);
|
||||
}
|
||||
|
||||
return targetBlocksFeeRatesSats;
|
||||
@@ -510,6 +610,16 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
public Double getMinimumRelayFee() throws ServerException {
|
||||
Double minFeeRateBtcKb = electrumServerRpc.getMinimumRelayFee(getTransport());
|
||||
if(minFeeRateBtcKb != null) {
|
||||
long minFeeRateSatsKb = (long)(minFeeRateBtcKb * Transaction.SATOSHIS_PER_BITCOIN);
|
||||
return minFeeRateSatsKb / 1000d;
|
||||
}
|
||||
|
||||
return Transaction.DEFAULT_MIN_RELAY_FEE;
|
||||
}
|
||||
|
||||
public Sha256Hash broadcastTransaction(Transaction transaction) throws ServerException {
|
||||
byte[] rawtxBytes = transaction.bitcoinSerialize();
|
||||
String rawtxHex = Utils.bytesToHex(rawtxBytes);
|
||||
@@ -527,6 +637,18 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
public static Map<String, WalletNode> getAllScriptHashes(Wallet wallet) {
|
||||
Map<String, WalletNode> scriptHashes = new HashMap<>();
|
||||
List<KeyPurpose> purposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
|
||||
for(KeyPurpose keyPurpose : purposes) {
|
||||
for(WalletNode childNode : wallet.getNode(keyPurpose).getChildren()) {
|
||||
scriptHashes.put(getScriptHash(wallet, childNode), childNode);
|
||||
}
|
||||
}
|
||||
|
||||
return scriptHashes;
|
||||
}
|
||||
|
||||
public static String getScriptHash(Wallet wallet, WalletNode node) {
|
||||
byte[] hash = Sha256Hash.hash(wallet.getOutputScript(node).getProgram());
|
||||
byte[] reversed = Utils.reverseBytes(hash);
|
||||
@@ -539,10 +661,24 @@ public class ElectrumServer {
|
||||
return Utils.bytesToHex(reversed);
|
||||
}
|
||||
|
||||
public static Map<String, String> getSubscribedScriptHashes() {
|
||||
public static Map<String, Set<String>> getSubscribedScriptHashes() {
|
||||
return subscribedScriptHashes;
|
||||
}
|
||||
|
||||
public static String getSubscribedScriptHashStatus(String scriptHash) {
|
||||
Set<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
|
||||
if(existingStatuses != null) {
|
||||
return Iterables.getLast(existingStatuses);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void updateSubscribedScriptHashStatus(String scriptHash, String status) {
|
||||
Set<String> existingStatuses = subscribedScriptHashes.computeIfAbsent(scriptHash, k -> new LinkedHashSet<>());
|
||||
existingStatuses.add(status);
|
||||
}
|
||||
|
||||
public static boolean supportsBatching(List<String> serverVersion) {
|
||||
return serverVersion.size() > 0 && serverVersion.get(0).toLowerCase().contains("electrumx");
|
||||
}
|
||||
@@ -572,12 +708,13 @@ public class ElectrumServer {
|
||||
}
|
||||
|
||||
public static class ConnectionService extends ScheduledService<FeeRatesUpdatedEvent> implements Thread.UncaughtExceptionHandler {
|
||||
private static final int FEE_RATES_PERIOD = 5 * 60 * 1000;
|
||||
private static final int FEE_RATES_PERIOD = 10 * 60 * 1000;
|
||||
|
||||
private final boolean subscribe;
|
||||
private boolean firstCall = true;
|
||||
private Thread reader;
|
||||
private long feeRatesRetrievedAt;
|
||||
private StringProperty statusProperty = new SimpleStringProperty();
|
||||
|
||||
public ConnectionService() {
|
||||
this(true);
|
||||
@@ -600,6 +737,9 @@ public class ElectrumServer {
|
||||
reader.setUncaughtExceptionHandler(ConnectionService.this);
|
||||
reader.start();
|
||||
|
||||
//Start with simple RPC for maximum compatibility
|
||||
electrumServerRpc = new SimpleElectrumServerRpc();
|
||||
|
||||
List<String> serverVersion = electrumServer.getServerVersion();
|
||||
firstCall = false;
|
||||
|
||||
@@ -607,8 +747,6 @@ public class ElectrumServer {
|
||||
if(supportsBatching(serverVersion)) {
|
||||
log.debug("Upgrading to batched JSON-RPC");
|
||||
electrumServerRpc = new BatchedElectrumServerRpc();
|
||||
} else {
|
||||
electrumServerRpc = new SimpleElectrumServerRpc();
|
||||
}
|
||||
|
||||
BlockHeaderTip tip;
|
||||
@@ -624,7 +762,12 @@ public class ElectrumServer {
|
||||
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE);
|
||||
feeRatesRetrievedAt = System.currentTimeMillis();
|
||||
|
||||
return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates);
|
||||
Double minimumRelayFeeRate = electrumServer.getMinimumRelayFee();
|
||||
for(Integer blockTarget : blockTargetFeeRates.keySet()) {
|
||||
blockTargetFeeRates.computeIfPresent(blockTarget, (blocks, feeRate) -> feeRate < minimumRelayFeeRate ? minimumRelayFeeRate : feeRate);
|
||||
}
|
||||
|
||||
return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates, minimumRelayFeeRate);
|
||||
} else {
|
||||
if(reader.isAlive()) {
|
||||
electrumServer.ping();
|
||||
@@ -675,6 +818,15 @@ public class ElectrumServer {
|
||||
public void uncaughtException(Thread t, Throwable e) {
|
||||
log.error("Uncaught error in ConnectionService", e);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void torStatus(TorStatusEvent event) {
|
||||
statusProperty.set(event.getStatus());
|
||||
}
|
||||
|
||||
public StringProperty statusProperty() {
|
||||
return statusProperty;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ReadRunnable implements Runnable {
|
||||
@@ -692,20 +844,31 @@ public class ElectrumServer {
|
||||
|
||||
public static class TransactionHistoryService extends Service<Boolean> {
|
||||
private final Wallet wallet;
|
||||
private final Set<WalletNode> nodes;
|
||||
private final static Map<Wallet, Object> walletSynchronizeLocks = new HashMap<>();
|
||||
|
||||
public TransactionHistoryService(Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
this.nodes = null;
|
||||
}
|
||||
|
||||
public TransactionHistoryService(Wallet wallet, Set<WalletNode> nodes) {
|
||||
this.wallet = wallet;
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() throws ServerException {
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = electrumServer.getHistory(wallet);
|
||||
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
|
||||
return true;
|
||||
walletSynchronizeLocks.putIfAbsent(wallet, new Object());
|
||||
synchronized(walletSynchronizeLocks.get(wallet)) {
|
||||
ElectrumServer electrumServer = new ElectrumServer();
|
||||
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
|
||||
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,5 +29,7 @@ public interface ElectrumServerRpc {
|
||||
|
||||
Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks);
|
||||
|
||||
Double getMinimumRelayFee(Transport transport);
|
||||
|
||||
String broadcastTransaction(Transport transport, String txHex);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ public enum Protocol {
|
||||
TCP {
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server) {
|
||||
if(isOnionAddress(server)) {
|
||||
return new TorTcpTransport(server);
|
||||
}
|
||||
|
||||
return new TcpTransport(server);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server, File serverCert) {
|
||||
return new TcpTransport(server);
|
||||
return getTransport(server);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -35,11 +39,19 @@ public enum Protocol {
|
||||
SSL {
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
|
||||
if(isOnionAddress(server)) {
|
||||
return new TorTcpOverTlsTransport(server);
|
||||
}
|
||||
|
||||
return new TcpOverTlsTransport(server);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
if(isOnionAddress(server)) {
|
||||
return new TorTcpOverTlsTransport(server, serverCert);
|
||||
}
|
||||
|
||||
return new TcpOverTlsTransport(server, serverCert);
|
||||
}
|
||||
|
||||
@@ -82,6 +94,10 @@ public enum Protocol {
|
||||
return toUrlString() + hostAndPort.toString();
|
||||
}
|
||||
|
||||
public boolean isOnionAddress(HostAndPort server) {
|
||||
return server.getHost().toLowerCase().endsWith(".onion");
|
||||
}
|
||||
|
||||
public static Protocol getProtocol(String url) {
|
||||
if(url.startsWith("tcp://")) {
|
||||
return TCP;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Generic retry logic. Delegate must throw the specified exception type to trigger the retry logic.
|
||||
*/
|
||||
public class RetryLogic<T> {
|
||||
public static interface Delegate<T> {
|
||||
T call() throws Exception;
|
||||
}
|
||||
|
||||
private final int maxAttempts;
|
||||
private final int retryWaitSeconds;
|
||||
@SuppressWarnings("rawtypes")
|
||||
private final List<Class> retryExceptionTypes;
|
||||
|
||||
public RetryLogic(int maxAttempts, int retryWaitSeconds, @SuppressWarnings("rawtypes") Class retryExceptionType) {
|
||||
this(maxAttempts, retryWaitSeconds, List.of(retryExceptionType));
|
||||
}
|
||||
|
||||
public RetryLogic(int maxAttempts, int retryWaitSeconds, @SuppressWarnings("rawtypes") List<Class> retryExceptionTypes) {
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.retryWaitSeconds = retryWaitSeconds;
|
||||
this.retryExceptionTypes = retryExceptionTypes;
|
||||
}
|
||||
|
||||
public T getResult(Delegate<T> caller) throws Exception {
|
||||
T result = null;
|
||||
int remainingAttempts = maxAttempts;
|
||||
do {
|
||||
try {
|
||||
return caller.call();
|
||||
} catch(Exception e) {
|
||||
if(retryExceptionTypes.contains(e.getClass())) {
|
||||
if(--remainingAttempts == 0) {
|
||||
throw new ServerException("Retries exhausted", e);
|
||||
} else {
|
||||
try {
|
||||
Thread.sleep((1000 * retryWaitSeconds));
|
||||
} catch(InterruptedException ie) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
} while(remainingAttempts > 0);
|
||||
|
||||
throw new IllegalStateException("Should be impossible");
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,29 @@ import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static com.sparrowwallet.drongo.protocol.Transaction.DUST_RELAY_TX_FEE;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
private static final Logger log = LoggerFactory.getLogger(SimpleElectrumServerRpc.class);
|
||||
private static final int MAX_TARGET_BLOCKS = 25;
|
||||
private static final int MAX_RETRIES = 10;
|
||||
private static final int RETRY_DELAY = 1;
|
||||
|
||||
private final AtomicLong idCounter = new AtomicLong();
|
||||
|
||||
@Override
|
||||
public void ping(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
client.createRequest().method("server.ping").id(1).executeNullable();
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
new RetryLogic<>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error pinging server", e);
|
||||
}
|
||||
}
|
||||
@@ -32,9 +38,10 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
public List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
//Using 1.4 as the version number as EPS tries to parse this number to a float
|
||||
return client.createRequest().returnAsList(String.class).method("server.version").id(1).params(clientName, "1.4").execute();
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
//Using 1.4 as the version number as EPS tries to parse this number to a float :(
|
||||
return new RetryLogic<List<String>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute());
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Error getting server version", e);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +50,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
public String getServerBanner(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAs(String.class).method("server.banner").id(1).execute();
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
@@ -53,8 +61,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
public BlockHeaderTip subscribeBlockHeaders(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(1).execute();
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
return new RetryLogic<BlockHeaderTip>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
@@ -65,10 +74,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
|
||||
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Loading transactions for " + path));
|
||||
try {
|
||||
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path).params(pathScriptHashes.get(path)).execute();
|
||||
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
|
||||
result.put(path, scriptHashTxes);
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
} catch(Exception e) {
|
||||
if(failOnError) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e);
|
||||
}
|
||||
@@ -87,9 +98,10 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
try {
|
||||
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(path).params(pathScriptHashes.get(path)).execute();
|
||||
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_mempool").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
|
||||
result.put(path, scriptHashTxes);
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
} catch(Exception e) {
|
||||
if(failOnError) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e);
|
||||
}
|
||||
@@ -107,10 +119,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for(String path : pathScriptHashes.keySet()) {
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Finding transactions for " + path));
|
||||
try {
|
||||
client.createRequest().method("blockchain.scripthash.subscribe").id(path).params(pathScriptHashes.get(path)).executeNullable();
|
||||
result.put(path, "");
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
String scriptHash = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
|
||||
result.put(path, scriptHash);
|
||||
} catch(Exception e) {
|
||||
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
|
||||
throw new ElectrumServerRpcException("Failed to retrieve reference for path: " + path, e);
|
||||
}
|
||||
@@ -125,13 +139,15 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
|
||||
Map<Integer, String> result = new LinkedHashMap<>();
|
||||
for(Integer blockHeight : blockHeights) {
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving block at height " + blockHeight));
|
||||
try {
|
||||
String blockHeader = client.createRequest().returnAs(String.class).method("blockchain.block.header").id(blockHeight).params(blockHeight).execute();
|
||||
String blockHeader = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("blockchain.block.header").id(idCounter.incrementAndGet()).params(blockHeight).execute());
|
||||
result.put(blockHeight, blockHeader);
|
||||
} catch(IllegalStateException | IllegalArgumentException e) {
|
||||
log.warn("Failed to retrieve block header for block height: " + blockHeight + " (" + e.getMessage() + ")");
|
||||
} catch(JsonRpcException e) {
|
||||
log.warn("Failed to retrieve block header for block height: " + blockHeight + " (" + e.getErrorMessage() + ")");
|
||||
} catch(Exception e) {
|
||||
log.warn("Failed to retrieve block header for block height: " + blockHeight + " (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +160,12 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
for(String txid : txids) {
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(false, "Retrieving transaction [" + txid.substring(0, 6) + "]"));
|
||||
try {
|
||||
String rawTxHex = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(txid).params(txid).execute();
|
||||
String rawTxHex = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute());
|
||||
result.put(txid, rawTxHex);
|
||||
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
|
||||
} catch(Exception e) {
|
||||
result.put(txid, Sha256Hash.ZERO_HASH.toString());
|
||||
}
|
||||
}
|
||||
@@ -162,22 +180,23 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
Map<String, VerboseTransaction> result = new LinkedHashMap<>();
|
||||
for(String txid : txids) {
|
||||
try {
|
||||
VerboseTransaction verboseTransaction = client.createRequest().returnAs(VerboseTransaction.class).method("blockchain.transaction.get").id(txid).params(txid, true).execute();
|
||||
VerboseTransaction verboseTransaction = new RetryLogic<VerboseTransaction>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(VerboseTransaction.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid, true).execute());
|
||||
result.put(txid, verboseTransaction);
|
||||
} catch(Exception e) {
|
||||
//electrs does not currently support the verbose parameter, so try to fetch an incomplete VerboseTransaction without it
|
||||
//electrs-esplora does not currently support the verbose parameter, so try to fetch an incomplete VerboseTransaction without it
|
||||
//Note that without the script hash associated with the transaction, we can't get a block height as there is no way in the Electrum RPC protocol to do this
|
||||
//We mark this VerboseTransaction as incomplete by assigning it a Sha256Hash.ZERO_HASH blockhash
|
||||
log.debug("Error retrieving transaction: " + txid + " (" + (e.getCause() != null ? e.getCause().getMessage() : e.getMessage()) + ")");
|
||||
|
||||
try {
|
||||
String rawTxHex = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(txid).params(txid).execute();
|
||||
String rawTxHex = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute();
|
||||
Transaction tx = new Transaction(Utils.hexToBytes(rawTxHex));
|
||||
String id = tx.getTxId().toString();
|
||||
int height = 0;
|
||||
|
||||
if(scriptHash != null) {
|
||||
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(id).params(scriptHash).execute();
|
||||
ScriptHashTx[] scriptHashTxes = client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(idCounter.incrementAndGet()).params(scriptHash).execute();
|
||||
for(ScriptHashTx scriptHashTx : scriptHashTxes) {
|
||||
if(scriptHashTx.tx_hash.equals(id)) {
|
||||
height = scriptHashTx.height;
|
||||
@@ -209,13 +228,14 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
for(Integer targetBlock : targetBlocks) {
|
||||
if(targetBlock <= MAX_TARGET_BLOCKS) {
|
||||
try {
|
||||
Double targetBlocksFeeRateBtcKb = client.createRequest().returnAs(Double.class).method("blockchain.estimatefee").id(targetBlock).params(targetBlock).execute();
|
||||
Double targetBlocksFeeRateBtcKb = new RetryLogic<Double>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
|
||||
client.createRequest().returnAs(Double.class).method("blockchain.estimatefee").id(idCounter.incrementAndGet()).params(targetBlock).execute());
|
||||
result.put(targetBlock, targetBlocksFeeRateBtcKb);
|
||||
} catch(IllegalStateException | IllegalArgumentException e) {
|
||||
log.warn("Failed to retrieve fee rate for target blocks: " + targetBlock + " (" + e.getMessage() + ")");
|
||||
result.put(targetBlock, result.values().stream().mapToDouble(v -> v).min().orElse(0.0001d));
|
||||
} catch(JsonRpcException e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve fee rate for target blocks: " + targetBlock, e);
|
||||
} catch(Exception e) {
|
||||
log.warn("Failed to retrieve fee rate for target blocks: " + targetBlock + " (" + e.getMessage() + ")");
|
||||
result.put(targetBlock, result.values().stream().mapToDouble(v -> v).min().orElse(0.0001d));
|
||||
}
|
||||
} else {
|
||||
result.put(targetBlock, result.values().stream().mapToDouble(v -> v).min().orElse(0.0001d));
|
||||
@@ -225,15 +245,27 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double getMinimumRelayFee(Transport transport) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return new RetryLogic<Double>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String broadcastTransaction(Transport transport, String txHex) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(1).params(txHex).execute();
|
||||
} catch(IllegalStateException | IllegalArgumentException e) {
|
||||
throw new ElectrumServerRpcException(e.getMessage(), e);
|
||||
return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, 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);
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.net;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
|
||||
import com.google.common.collect.Iterables;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.NewBlockEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletNodeHistoryChangedEvent;
|
||||
@@ -10,7 +11,7 @@ import javafx.application.Platform;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@JsonRpcService
|
||||
public class SubscriptionService {
|
||||
@@ -23,9 +24,17 @@ public class SubscriptionService {
|
||||
|
||||
@JsonRpcMethod("blockchain.scripthash.subscribe")
|
||||
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcParam("status") final String status) {
|
||||
String oldStatus = ElectrumServer.getSubscribedScriptHashes().put(scriptHash, status);
|
||||
if(Objects.equals(oldStatus, status)) {
|
||||
Set<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
|
||||
if(existingStatuses == null) {
|
||||
log.warn("Received script hash status update for unsubscribed script hash: " + scriptHash);
|
||||
ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status);
|
||||
} else if(existingStatuses.contains(status)) {
|
||||
log.warn("Received script hash status update, but status has not changed");
|
||||
return;
|
||||
} else {
|
||||
String oldStatus = Iterables.getLast(existingStatuses);
|
||||
log.debug("Status updated for script hash " + scriptHash + ", was " + oldStatus + " now " + status);
|
||||
existingStatuses.add(status);
|
||||
}
|
||||
|
||||
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash)));
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.net;
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.github.arteam.simplejsonrpc.server.JsonRpcServer;
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.google.gson.Gson;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
@@ -11,12 +12,16 @@ import org.slf4j.LoggerFactory;
|
||||
import javax.net.SocketFactory;
|
||||
import java.io.*;
|
||||
import java.net.Socket;
|
||||
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 {
|
||||
private static final Logger log = LoggerFactory.getLogger(TcpTransport.class);
|
||||
|
||||
public static final int DEFAULT_PORT = 50001;
|
||||
private static final int[] READ_TIMEOUT_SECS = {3, 8, 16, 34};
|
||||
|
||||
protected final HostAndPort server;
|
||||
protected final SocketFactory socketFactory;
|
||||
@@ -25,15 +30,20 @@ public class TcpTransport implements Transport, Closeable {
|
||||
|
||||
private String response;
|
||||
|
||||
private final ReentrantLock readLock = new ReentrantLock();
|
||||
private final Condition readingCondition = readLock.newCondition();
|
||||
|
||||
private final ReentrantLock clientRequestLock = new ReentrantLock();
|
||||
private boolean running = false;
|
||||
private boolean reading = true;
|
||||
private volatile boolean reading = true;
|
||||
private boolean firstRead = true;
|
||||
private int readTimeoutIndex;
|
||||
|
||||
private final JsonRpcServer jsonRpcServer = new JsonRpcServer();
|
||||
private final SubscriptionService subscriptionService = new SubscriptionService();
|
||||
|
||||
private Exception lastException;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
public TcpTransport(HostAndPort server) {
|
||||
this.server = server;
|
||||
@@ -44,8 +54,17 @@ public class TcpTransport implements Transport, Closeable {
|
||||
public @NotNull String pass(@NotNull String request) throws IOException {
|
||||
clientRequestLock.lock();
|
||||
try {
|
||||
Rpc sentRpc = request.startsWith("{") ? gson.fromJson(request, Rpc.class) : null;
|
||||
Rpc recvRpc;
|
||||
String recv;
|
||||
|
||||
writeRequest(request);
|
||||
return readResponse();
|
||||
do {
|
||||
recv = readResponse();
|
||||
recvRpc = recv.startsWith("{") ? gson.fromJson(response, Rpc.class) : null;
|
||||
} while(!Objects.equals(recvRpc, sentRpc));
|
||||
|
||||
return recv;
|
||||
} finally {
|
||||
clientRequestLock.unlock();
|
||||
}
|
||||
@@ -57,64 +76,89 @@ public class TcpTransport implements Transport, Closeable {
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private synchronized String readResponse() throws IOException {
|
||||
if(firstRead) {
|
||||
notifyAll();
|
||||
firstRead = false;
|
||||
}
|
||||
|
||||
while(reading) {
|
||||
try {
|
||||
wait();
|
||||
} catch(InterruptedException e) {
|
||||
//Restore interrupt status and continue
|
||||
Thread.currentThread().interrupt();
|
||||
private String readResponse() throws IOException {
|
||||
try {
|
||||
if(!readLock.tryLock(READ_TIMEOUT_SECS[readTimeoutIndex], TimeUnit.SECONDS)) {
|
||||
readTimeoutIndex = Math.min(readTimeoutIndex + 1, READ_TIMEOUT_SECS.length - 1);
|
||||
log.debug("No response from server, setting read timeout to " + READ_TIMEOUT_SECS[readTimeoutIndex] + " secs");
|
||||
throw new IOException("No response from server");
|
||||
}
|
||||
} catch(InterruptedException e) {
|
||||
throw new IOException("Read thread interrupted");
|
||||
}
|
||||
|
||||
if(lastException != null) {
|
||||
throw new IOException("Error reading response: " + lastException.getMessage(), lastException);
|
||||
if(readTimeoutIndex == READ_TIMEOUT_SECS.length - 1) {
|
||||
readTimeoutIndex--;
|
||||
}
|
||||
|
||||
reading = true;
|
||||
try {
|
||||
if(firstRead) {
|
||||
readingCondition.signal();
|
||||
firstRead = false;
|
||||
}
|
||||
|
||||
notifyAll();
|
||||
return response;
|
||||
while(reading) {
|
||||
try {
|
||||
readingCondition.await();
|
||||
} catch(InterruptedException e) {
|
||||
//Restore interrupt status and continue
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
if(lastException != null) {
|
||||
throw new IOException("Error reading response: " + lastException.getMessage(), lastException);
|
||||
}
|
||||
|
||||
reading = true;
|
||||
|
||||
readingCondition.signal();
|
||||
return response;
|
||||
} finally {
|
||||
readLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void readInputLoop() throws ServerException {
|
||||
try {
|
||||
//Don't start reading until first RPC request is sent
|
||||
wait();
|
||||
} catch(InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
public void readInputLoop() throws ServerException {
|
||||
readLock.lock();
|
||||
|
||||
while(running) {
|
||||
try {
|
||||
try {
|
||||
String received = readInputStream();
|
||||
if(received.contains("method")) {
|
||||
//Handle subscription notification
|
||||
jsonRpcServer.handle(received, subscriptionService);
|
||||
} else {
|
||||
//Handle client's response
|
||||
response = received;
|
||||
reading = false;
|
||||
notifyAll();
|
||||
wait();
|
||||
}
|
||||
//Don't start reading until first RPC request is sent
|
||||
readingCondition.await();
|
||||
} catch(InterruptedException e) {
|
||||
//Restore interrupt status and continue
|
||||
Thread.currentThread().interrupt();
|
||||
} catch(Exception e) {
|
||||
if(running) {
|
||||
lastException = e;
|
||||
reading = false;
|
||||
notifyAll();
|
||||
//Allow this thread to terminate as we will need to reconnect with a new transport anyway
|
||||
running = false;
|
||||
}
|
||||
|
||||
while(running) {
|
||||
try {
|
||||
String received = readInputStream();
|
||||
if(received.contains("method") && !received.contains("error")) {
|
||||
//Handle subscription notification
|
||||
jsonRpcServer.handle(received, subscriptionService);
|
||||
} else {
|
||||
//Handle client's response
|
||||
response = received;
|
||||
reading = false;
|
||||
readingCondition.signal();
|
||||
readingCondition.await();
|
||||
}
|
||||
} catch(InterruptedException e) {
|
||||
//Restore interrupt status and continue
|
||||
Thread.currentThread().interrupt();
|
||||
} catch(Exception e) {
|
||||
log.debug("Connection error while reading", e);
|
||||
if(running) {
|
||||
lastException = e;
|
||||
reading = false;
|
||||
readingCondition.signal();
|
||||
//Allow this thread to terminate as we will need to reconnect with a new transport anyway
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
readLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,4 +197,25 @@ public class TcpTransport implements Transport, Closeable {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static class Rpc {
|
||||
public String id;
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if(this == o) {
|
||||
return true;
|
||||
}
|
||||
if(o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
Rpc rpc = (Rpc) o;
|
||||
return Objects.equals(id, rpc.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.google.common.net.HostAndPort;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.Socket;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
|
||||
public class TorTcpOverTlsTransport extends TcpOverTlsTransport {
|
||||
private static final Logger log = LoggerFactory.getLogger(TorTcpOverTlsTransport.class);
|
||||
|
||||
public TorTcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException {
|
||||
super(server);
|
||||
}
|
||||
|
||||
public TorTcpOverTlsTransport(HostAndPort server, File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
|
||||
super(server, crtFile);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Socket createSocket() throws IOException {
|
||||
TorTcpTransport torTcpTransport = new TorTcpTransport(server);
|
||||
Socket socket = torTcpTransport.createSocket();
|
||||
|
||||
try {
|
||||
Field socketField = socket.getClass().getDeclaredField("socket");
|
||||
socketField.setAccessible(true);
|
||||
Socket innerSocket = (Socket)socketField.get(socket);
|
||||
Field connectedField = innerSocket.getClass().getSuperclass().getDeclaredField("connected");
|
||||
connectedField.setAccessible(true);
|
||||
connectedField.set(innerSocket, true);
|
||||
} catch(Exception e) {
|
||||
log.error("Could not set socket connected status", e);
|
||||
}
|
||||
|
||||
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socket, server.getHost(), server.getPortOrDefault(DEFAULT_PORT), true);
|
||||
sslSocket.startHandshake();
|
||||
|
||||
return sslSocket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.StatusEvent;
|
||||
import com.sparrowwallet.sparrow.event.TorStatusEvent;
|
||||
import javafx.application.Platform;
|
||||
import org.berndpruenster.netlayer.tor.*;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.Socket;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
public class TorTcpTransport extends TcpTransport {
|
||||
public static final String TOR_DIR_PREFIX = "tor";
|
||||
|
||||
public TorTcpTransport(HostAndPort server) {
|
||||
super(server);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Socket createSocket() throws IOException {
|
||||
if(Tor.getDefault() == null) {
|
||||
Platform.runLater(() -> {
|
||||
String status = "Starting Tor...";
|
||||
EventManager.get().post(new TorStatusEvent(status));
|
||||
});
|
||||
|
||||
Path path = Files.createTempDirectory(TOR_DIR_PREFIX);
|
||||
File torInstallDir = path.toFile();
|
||||
torInstallDir.deleteOnExit();
|
||||
try {
|
||||
LinkedHashMap<String, String> torrcOptionsMap = new LinkedHashMap<>();
|
||||
torrcOptionsMap.put("DisableNetwork", "0");
|
||||
Torrc override = new Torrc(torrcOptionsMap);
|
||||
|
||||
NativeTor nativeTor = new NativeTor(torInstallDir, Collections.emptyList(), override);
|
||||
Tor.setDefault(nativeTor);
|
||||
} catch(TorCtlException e) {
|
||||
e.printStackTrace();
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
String status = "Tor running, connecting to " + server.toString() + "...";
|
||||
EventManager.get().post(new TorStatusEvent(status));
|
||||
});
|
||||
|
||||
return new TorSocket(server.getHost(), server.getPort(), "sparrow");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.sparrow.MainApp;
|
||||
import com.sparrowwallet.sparrow.event.VersionUpdatedEvent;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URL;
|
||||
import java.security.SignatureException;
|
||||
import java.util.Map;
|
||||
|
||||
public class VersionCheckService extends ScheduledService<VersionUpdatedEvent> {
|
||||
private static final Logger log = LoggerFactory.getLogger(VersionCheckService.class);
|
||||
private static final String VERSION_CHECK_URL = "https://www.sparrowwallet.com/version";
|
||||
|
||||
@Override
|
||||
protected Task<VersionUpdatedEvent> createTask() {
|
||||
return new Task<>() {
|
||||
protected VersionUpdatedEvent call() {
|
||||
try {
|
||||
VersionCheck versionCheck = getVersionCheck();
|
||||
if(isNewer(versionCheck) && verifySignature(versionCheck)) {
|
||||
return new VersionUpdatedEvent(versionCheck.version);
|
||||
}
|
||||
} catch(IOException e) {
|
||||
log.error("Error retrieving version check file", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private VersionCheck getVersionCheck() throws IOException {
|
||||
URL url = new URL(VERSION_CHECK_URL);
|
||||
HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();
|
||||
|
||||
try(InputStreamReader reader = new InputStreamReader(conn.getInputStream())) {
|
||||
Gson gson = new Gson();
|
||||
return gson.fromJson(reader, VersionCheck.class);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifySignature(VersionCheck versionCheck) {
|
||||
try {
|
||||
for(String addressString : versionCheck.signatures.keySet()) {
|
||||
if(!addressString.equals("1LiJx1HQ49L2LzhBwbgwXdHiGodvPg5YaV")) {
|
||||
log.warn("Invalid address for version check " + addressString);
|
||||
continue;
|
||||
}
|
||||
|
||||
String signature = versionCheck.signatures.get(addressString);
|
||||
ECKey signedMessageKey = ECKey.signedMessageToKey(versionCheck.version, signature, false);
|
||||
Address providedAddress = Address.fromString(addressString);
|
||||
Address signedMessageAddress = ScriptType.P2PKH.getAddress(signedMessageKey);
|
||||
|
||||
if(providedAddress.equals(signedMessageAddress)) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("Invalid signature for version check " + signature + " from address " + addressString);
|
||||
}
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
log.error("Error in version check signature", e);
|
||||
} catch(InvalidAddressException e) {
|
||||
log.error("Error in version check address", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isNewer(VersionCheck versionCheck) {
|
||||
try {
|
||||
Version versionCheckVersion = new Version(versionCheck.version);
|
||||
Version currentVersion = new Version(MainApp.APP_VERSION);
|
||||
return versionCheckVersion.compareTo(currentVersion) > 0;
|
||||
} catch(IllegalArgumentException e) {
|
||||
log.error("Invalid versions to compare: " + versionCheck.version + " to " + MainApp.APP_VERSION, e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static class VersionCheck {
|
||||
public String version;
|
||||
public Map<String, String> signatures;
|
||||
}
|
||||
|
||||
public static class Version implements Comparable<Version> {
|
||||
private final String version;
|
||||
|
||||
public final String get() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public Version(String version) {
|
||||
if(version == null) {
|
||||
throw new IllegalArgumentException("Version can not be null");
|
||||
}
|
||||
if(!version.matches("[0-9]+(\\.[0-9]+)*")) {
|
||||
throw new IllegalArgumentException("Invalid version format");
|
||||
}
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Version that) {
|
||||
if(that == null) {
|
||||
return 1;
|
||||
}
|
||||
String[] thisParts = this.get().split("\\.");
|
||||
String[] thatParts = that.get().split("\\.");
|
||||
int length = Math.max(thisParts.length, thatParts.length);
|
||||
for(int i = 0; i < length; i++) {
|
||||
int thisPart = i < thisParts.length ? Integer.parseInt(thisParts[i]) : 0;
|
||||
int thatPart = i < thatParts.length ? Integer.parseInt(thatParts[i]) : 0;
|
||||
if(thisPart < thatPart) {
|
||||
return -1;
|
||||
}
|
||||
if(thisPart > thatPart) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object that) {
|
||||
if(this == that) {
|
||||
return true;
|
||||
}
|
||||
if(that == null) {
|
||||
return false;
|
||||
}
|
||||
if(this.getClass() != that.getClass()) {
|
||||
return false;
|
||||
}
|
||||
return this.compareTo((Version)that) == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.sparrowwallet.sparrow.payjoin;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.gson.Gson;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionInput;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTOutput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTParseException;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.*;
|
||||
|
||||
public class Payjoin {
|
||||
private static final Logger log = LoggerFactory.getLogger(Payjoin.class);
|
||||
|
||||
private final BitcoinURI payjoinURI;
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
|
||||
public Payjoin(BitcoinURI payjoinURI, Wallet wallet, PSBT psbt) {
|
||||
this.payjoinURI = payjoinURI;
|
||||
this.wallet = wallet;
|
||||
this.psbt = psbt;
|
||||
|
||||
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
|
||||
if(psbtInput.getUtxo() == null) {
|
||||
throw new IllegalArgumentException("Original PSBT for payjoin transaction must have non_witness_utxo or witness_utxo fields for all inputs");
|
||||
}
|
||||
}
|
||||
|
||||
if(!psbt.isFinalized()) {
|
||||
throw new IllegalArgumentException("Original PSBT for payjoin transaction must be finalized");
|
||||
}
|
||||
}
|
||||
|
||||
public PSBT requestPayjoinPSBT(boolean allowOutputSubstitution) throws PayjoinReceiverException {
|
||||
if(!payjoinURI.isPayjoinOutputSubstitutionAllowed()) {
|
||||
allowOutputSubstitution = false;
|
||||
}
|
||||
|
||||
URI uri = payjoinURI.getPayjoinUrl();
|
||||
if(uri == null) {
|
||||
log.error("No payjoin URL provided");
|
||||
throw new PayjoinReceiverException("No payjoin URL provided");
|
||||
}
|
||||
|
||||
try {
|
||||
String base64Psbt = psbt.getPublicCopy().toBase64String();
|
||||
|
||||
String appendQuery = "v=1";
|
||||
int changeOutputIndex = getChangeOutputIndex();
|
||||
long maxAdditionalFeeContribution = 0;
|
||||
if(changeOutputIndex > -1) {
|
||||
appendQuery += "&additionalfeeoutputindex=" + changeOutputIndex;
|
||||
maxAdditionalFeeContribution = getAdditionalFeeContribution();
|
||||
appendQuery += "&maxadditionalfeecontribution=" + maxAdditionalFeeContribution;
|
||||
}
|
||||
|
||||
URI finalUri = new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery() == null ? appendQuery : uri.getQuery() + "&" + appendQuery, uri.getFragment());
|
||||
log.info("Sending PSBT to " + finalUri.toURL());
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(finalUri)
|
||||
.header("Content-Type", "text/plain")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(base64Psbt))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if(response.statusCode() != 200) {
|
||||
Gson gson = new Gson();
|
||||
PayjoinReceiverError payjoinReceiverError = gson.fromJson(response.body(), PayjoinReceiverError.class);
|
||||
log.warn("Payjoin receiver returned an error of " + payjoinReceiverError.getErrorCode() + " (" + payjoinReceiverError.getMessage() + ")");
|
||||
throw new PayjoinReceiverException(payjoinReceiverError.getSafeMessage());
|
||||
}
|
||||
|
||||
PSBT proposalPsbt = PSBT.fromString(response.body());
|
||||
checkProposal(psbt, proposalPsbt, changeOutputIndex, maxAdditionalFeeContribution, allowOutputSubstitution);
|
||||
|
||||
return proposalPsbt;
|
||||
} catch(URISyntaxException e) {
|
||||
log.error("Invalid payjoin receiver URI", e);
|
||||
throw new PayjoinReceiverException("Invalid payjoin receiver URI", e);
|
||||
} catch(IOException | InterruptedException e) {
|
||||
log.error("Payjoin receiver error", e);
|
||||
throw new PayjoinReceiverException("Payjoin receiver error", e);
|
||||
} catch(PSBTParseException e) {
|
||||
log.error("Error parsing received PSBT", e);
|
||||
throw new PayjoinReceiverException("Payjoin receiver returned invalid PSBT", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkProposal(PSBT original, PSBT proposal, int changeOutputIndex, long maxAdditionalFeeContribution, boolean allowOutputSubstitution) throws PayjoinReceiverException {
|
||||
Queue<Map.Entry<TransactionInput, PSBTInput>> originalInputs = new ArrayDeque<>();
|
||||
for(int i = 0; i < original.getPsbtInputs().size(); i++) {
|
||||
originalInputs.add(Map.entry(original.getTransaction().getInputs().get(i), original.getPsbtInputs().get(i)));
|
||||
}
|
||||
|
||||
Queue<Map.Entry<TransactionOutput, PSBTOutput>> originalOutputs = new ArrayDeque<>();
|
||||
for(int i = 0; i < original.getPsbtOutputs().size(); i++) {
|
||||
originalOutputs.add(Map.entry(original.getTransaction().getOutputs().get(i), original.getPsbtOutputs().get(i)));
|
||||
}
|
||||
|
||||
// Checking that the PSBT of the receiver is clean
|
||||
if(!proposal.getExtendedPublicKeys().isEmpty()) {
|
||||
throw new PayjoinReceiverException("Global xpubs should not be included in the receiver's PSBT");
|
||||
}
|
||||
|
||||
Transaction originalTx = original.getTransaction();
|
||||
Transaction proposalTx = proposal.getTransaction();
|
||||
// Verify that the transaction version, and nLockTime are unchanged.
|
||||
if(proposalTx.getVersion() != originalTx.getVersion()) {
|
||||
throw new PayjoinReceiverException("The proposal PSBT changed the transaction version");
|
||||
}
|
||||
if(proposalTx.getLocktime() != originalTx.getLocktime()) {
|
||||
throw new PayjoinReceiverException("The proposal PSBT changed the nLocktime");
|
||||
}
|
||||
|
||||
Set<Long> sequences = new HashSet<>();
|
||||
// For each inputs in the proposal:
|
||||
for(PSBTInput proposedPSBTInput : proposal.getPsbtInputs()) {
|
||||
if(!proposedPSBTInput.getDerivedPublicKeys().isEmpty()) {
|
||||
throw new PayjoinReceiverException("The receiver added keypaths to an input");
|
||||
}
|
||||
if(!proposedPSBTInput.getPartialSignatures().isEmpty()) {
|
||||
throw new PayjoinReceiverException("The receiver added partial signatures to an input");
|
||||
}
|
||||
|
||||
TransactionInput proposedTxIn = proposedPSBTInput.getInput();
|
||||
boolean isOriginalInput = originalInputs.size() > 0 && originalInputs.peek().getKey().getOutpoint().equals(proposedTxIn.getOutpoint());
|
||||
if(isOriginalInput) {
|
||||
Map.Entry<TransactionInput, PSBTInput> originalInput = originalInputs.remove();
|
||||
TransactionInput originalTxIn = originalInput.getKey();
|
||||
|
||||
// Verify that sequence is unchanged.
|
||||
if(originalTxIn.getSequenceNumber() != proposedTxIn.getSequenceNumber()) {
|
||||
throw new PayjoinReceiverException("The proposed transaction input modified the sequence of one of the original inputs");
|
||||
}
|
||||
// Verify the PSBT input is not finalized
|
||||
if(proposedPSBTInput.isFinalized()) {
|
||||
throw new PayjoinReceiverException("The receiver finalized one of the original inputs");
|
||||
}
|
||||
// Verify that non_witness_utxo and witness_utxo are not specified.
|
||||
if(proposedPSBTInput.getNonWitnessUtxo() != null || proposedPSBTInput.getWitnessUtxo() != null) {
|
||||
throw new PayjoinReceiverException("The receiver added non_witness_utxo or witness_utxo to one of the original inputs");
|
||||
}
|
||||
sequences.add(proposedTxIn.getSequenceNumber());
|
||||
|
||||
PSBTInput originalPSBTInput = originalInput.getValue();
|
||||
// Fill up the info from the original PSBT input so we can sign and get fees.
|
||||
proposedPSBTInput.setNonWitnessUtxo(originalPSBTInput.getNonWitnessUtxo());
|
||||
proposedPSBTInput.setWitnessUtxo(originalPSBTInput.getWitnessUtxo());
|
||||
// We fill up information we had on the signed PSBT, so we can sign it.
|
||||
proposedPSBTInput.getDerivedPublicKeys().putAll(originalPSBTInput.getDerivedPublicKeys());
|
||||
proposedPSBTInput.getProprietary().putAll(originalPSBTInput.getProprietary());
|
||||
proposedPSBTInput.setRedeemScript(originalPSBTInput.getFinalScriptSig().getFirstNestedScript());
|
||||
proposedPSBTInput.setWitnessScript(originalPSBTInput.getFinalScriptWitness().getWitnessScript());
|
||||
proposedPSBTInput.setSigHash(originalPSBTInput.getSigHash());
|
||||
} else {
|
||||
// Verify the PSBT input is finalized
|
||||
if(!proposedPSBTInput.isFinalized()) {
|
||||
throw new PayjoinReceiverException("The receiver did not finalize one of their inputs");
|
||||
}
|
||||
// Verify that non_witness_utxo or witness_utxo are filled in.
|
||||
if(proposedPSBTInput.getNonWitnessUtxo() == null && proposedPSBTInput.getWitnessUtxo() == null) {
|
||||
throw new PayjoinReceiverException("The receiver did not specify non_witness_utxo or witness_utxo for one of their inputs");
|
||||
}
|
||||
sequences.add(proposedTxIn.getSequenceNumber());
|
||||
// Verify that the payjoin proposal did not introduced mixed inputs' type.
|
||||
if(wallet.getScriptType() != proposedPSBTInput.getScriptType()) {
|
||||
throw new PayjoinReceiverException("Proposal script type of " + proposedPSBTInput.getScriptType() + " did not match wallet script type of " + wallet.getScriptType());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that all of sender's inputs from the original PSBT are in the proposal.
|
||||
if(!originalInputs.isEmpty()) {
|
||||
throw new PayjoinReceiverException("Some of the original inputs are not included in the proposal");
|
||||
}
|
||||
|
||||
// Verify that the payjoin proposal did not introduced mixed inputs' sequence.
|
||||
if(sequences.size() != 1) {
|
||||
throw new PayjoinReceiverException("Mixed sequences detected in the proposal");
|
||||
}
|
||||
|
||||
Long newFee = proposal.getFee();
|
||||
long additionalFee = newFee - original.getFee();
|
||||
if(additionalFee < 0) {
|
||||
throw new PayjoinReceiverException("The receiver decreased absolute fee");
|
||||
}
|
||||
|
||||
TransactionOutput changeOutput = (changeOutputIndex > -1 ? originalTx.getOutputs().get(changeOutputIndex) : null);
|
||||
|
||||
// For each outputs in the proposal:
|
||||
for(int i = 0; i < proposal.getPsbtOutputs().size(); i++) {
|
||||
PSBTOutput proposedPSBTOutput = proposal.getPsbtOutputs().get(i);
|
||||
// Verify that no keypaths is in the PSBT output
|
||||
if(!proposedPSBTOutput.getDerivedPublicKeys().isEmpty()) {
|
||||
throw new PayjoinReceiverException("The receiver added keypaths to an output");
|
||||
}
|
||||
|
||||
TransactionOutput proposedTxOut = proposalTx.getOutputs().get(i);
|
||||
boolean isOriginalOutput = originalOutputs.size() > 0 && originalOutputs.peek().getKey().getScript().equals(proposedTxOut.getScript());
|
||||
if(isOriginalOutput) {
|
||||
Map.Entry<TransactionOutput, PSBTOutput> originalOutput = originalOutputs.remove();
|
||||
if(originalOutput.getKey() == changeOutput) {
|
||||
var actualContribution = changeOutput.getValue() - proposedTxOut.getValue();
|
||||
// The amount that was subtracted from the output's value is less than or equal to maxadditionalfeecontribution
|
||||
if(actualContribution > maxAdditionalFeeContribution) {
|
||||
throw new PayjoinReceiverException("The actual contribution is more than maxadditionalfeecontribution");
|
||||
}
|
||||
// Make sure the actual contribution is only paying fee
|
||||
if(actualContribution > additionalFee) {
|
||||
throw new PayjoinReceiverException("The actual contribution is not only paying fee");
|
||||
}
|
||||
// Make sure the actual contribution is only paying for fee incurred by additional inputs
|
||||
int additionalInputsCount = proposalTx.getInputs().size() - originalTx.getInputs().size();
|
||||
if(actualContribution > getSingleInputFee() * additionalInputsCount) {
|
||||
throw new PayjoinReceiverException("The actual contribution is not only paying for additional inputs");
|
||||
}
|
||||
} else if(allowOutputSubstitution && originalOutput.getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) {
|
||||
// That's the payment output, the receiver may have changed it.
|
||||
} else {
|
||||
if(originalOutput.getKey().getValue() > proposedTxOut.getValue()) {
|
||||
throw new PayjoinReceiverException("The receiver decreased the value of one of the outputs");
|
||||
}
|
||||
}
|
||||
|
||||
PSBTOutput originalPSBTOutput = originalOutput.getValue();
|
||||
// We fill up information we had on the signed PSBT, so we can sign it.
|
||||
proposedPSBTOutput.getDerivedPublicKeys().putAll(originalPSBTOutput.getDerivedPublicKeys());
|
||||
proposedPSBTOutput.getProprietary().putAll(originalPSBTOutput.getProprietary());
|
||||
proposedPSBTOutput.setRedeemScript(originalPSBTOutput.getRedeemScript());
|
||||
proposedPSBTOutput.setWitnessScript(originalPSBTOutput.getWitnessScript());
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that all of sender's outputs from the original PSBT are in the proposal.
|
||||
if(!originalOutputs.isEmpty()) {
|
||||
// The payment output may have been substituted
|
||||
if(!allowOutputSubstitution || originalOutputs.size() != 1 || !originalOutputs.remove().getKey().getScript().equals(payjoinURI.getAddress().getOutputScript())) {
|
||||
throw new PayjoinReceiverException("Some of our outputs are not included in the proposal");
|
||||
}
|
||||
}
|
||||
|
||||
//Add global pubkey map for signing
|
||||
proposal.getExtendedPublicKeys().putAll(psbt.getExtendedPublicKeys());
|
||||
proposal.getGlobalProprietary().putAll(psbt.getGlobalProprietary());
|
||||
}
|
||||
|
||||
private int getChangeOutputIndex() {
|
||||
Map<Script, WalletNode> changeScriptNodes = wallet.getWalletOutputScripts(KeyPurpose.CHANGE);
|
||||
for(int i = 0; i < psbt.getTransaction().getOutputs().size(); i++) {
|
||||
if(changeScriptNodes.containsKey(psbt.getTransaction().getOutputs().get(i).getScript())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private long getAdditionalFeeContribution() {
|
||||
return getSingleInputFee();
|
||||
}
|
||||
|
||||
private long getSingleInputFee() {
|
||||
Transaction transaction = psbt.extractTransaction();
|
||||
double feeRate = psbt.getFee().doubleValue() / transaction.getVirtualSize();
|
||||
int vSize = 68;
|
||||
|
||||
if(transaction.getInputs().size() > 0) {
|
||||
TransactionInput input = transaction.getInputs().get(0);
|
||||
vSize = input.getLength() * Transaction.WITNESS_SCALE_FACTOR;
|
||||
vSize += input.getWitness() != null ? input.getWitness().getLength() : 0;
|
||||
vSize = (int)Math.ceil((double)vSize / Transaction.WITNESS_SCALE_FACTOR);
|
||||
}
|
||||
|
||||
return (long) (vSize * feeRate);
|
||||
}
|
||||
|
||||
private static class PayjoinReceiverError {
|
||||
Map<String, String> knownErrors = ImmutableMap.of(
|
||||
"unavailable", "The payjoin endpoint is not available for now.",
|
||||
"not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal.",
|
||||
"version-unsupported", "This version of payjoin is not supported.",
|
||||
"original-psbt-rejected", "The receiver rejected the original PSBT."
|
||||
);
|
||||
|
||||
public String errorCode;
|
||||
public String message;
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public String getSafeMessage() {
|
||||
String message = knownErrors.get(errorCode);
|
||||
return (message == null ? "Unknown Error" : message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sparrowwallet.sparrow.payjoin;
|
||||
|
||||
public class PayjoinReceiverException extends Exception {
|
||||
public PayjoinReceiverException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public PayjoinReceiverException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
public PayjoinReceiverException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public PayjoinReceiverException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
|
||||
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
|
||||
import com.sparrowwallet.sparrow.event.VersionCheckStatusEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.ExchangeSource;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
@@ -38,6 +39,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch notifyNewTransactions;
|
||||
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch checkNewVersions;
|
||||
|
||||
private final ChangeListener<Currency> fiatCurrencyListener = new ChangeListener<Currency>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Currency> observable, Currency oldValue, Currency newValue) {
|
||||
@@ -86,6 +90,12 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
notifyNewTransactions.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setNotifyNewTransactions(newValue);
|
||||
});
|
||||
|
||||
checkNewVersions.setSelected(config.isCheckNewVersions());
|
||||
checkNewVersions.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
|
||||
config.setCheckNewVersions(newValue);
|
||||
EventManager.get().post(new VersionCheckStatusEvent(newValue));
|
||||
});
|
||||
}
|
||||
|
||||
private void updateCurrencies(ExchangeSource exchangeSource) {
|
||||
|
||||
@@ -31,7 +31,7 @@ public class PreferencesDialog extends Dialog<Boolean> {
|
||||
|
||||
try {
|
||||
FXMLLoader preferencesLoader = new FXMLLoader(AppController.class.getResource("preferences/preferences.fxml"));
|
||||
dialogPane.setContent(Borders.wrap(preferencesLoader.load()).lineBorder().outerPadding(0).innerPadding(0).buildAll());
|
||||
dialogPane.setContent(Borders.wrap(preferencesLoader.load()).emptyBorder().buildAll());
|
||||
PreferencesController preferencesController = preferencesLoader.getController();
|
||||
preferencesController.initializeView(Config.get());
|
||||
if(initialGroup != null) {
|
||||
|
||||
+20
-8
@@ -28,6 +28,8 @@ import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import java.io.File;
|
||||
@@ -36,6 +38,8 @@ import java.security.cert.CertificateFactory;
|
||||
import java.util.List;
|
||||
|
||||
public class ServerPreferencesController extends PreferencesDetailController {
|
||||
private static final Logger log = LoggerFactory.getLogger(ServerPreferencesController.class);
|
||||
|
||||
@FXML
|
||||
private TextField host;
|
||||
|
||||
@@ -105,7 +109,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Select Electrum Server certificate");
|
||||
fileChooser.getExtensionFilters().addAll(
|
||||
new FileChooser.ExtensionFilter("All Files", "*.*"),
|
||||
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
|
||||
new FileChooser.ExtensionFilter("CRT", "*.crt")
|
||||
);
|
||||
|
||||
@@ -140,13 +144,20 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
|
||||
|
||||
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService(false);
|
||||
connectionService.setPeriod(Duration.minutes(1));
|
||||
connectionService.setPeriod(Duration.ZERO);
|
||||
EventManager.get().register(connectionService);
|
||||
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
|
||||
testResults.setText(testResults.getText() + "\n" + newValue);
|
||||
});
|
||||
|
||||
connectionService.setOnSucceeded(successEvent -> {
|
||||
EventManager.get().unregister(connectionService);
|
||||
ConnectionEvent connectionEvent = (ConnectionEvent)connectionService.getValue();
|
||||
showConnectionSuccess(connectionEvent.getServerVersion(), connectionEvent.getServerBanner());
|
||||
connectionService.cancel();
|
||||
});
|
||||
connectionService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().unregister(connectionService);
|
||||
showConnectionFailure(workerStateEvent);
|
||||
connectionService.cancel();
|
||||
});
|
||||
@@ -216,7 +227,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
}
|
||||
|
||||
private void showConnectionSuccess(List<String> serverVersion, String serverBanner) {
|
||||
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE, Color.rgb(80, 161, 79)));
|
||||
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE, "success"));
|
||||
if(serverVersion != null) {
|
||||
testResults.setText("Connected to " + serverVersion.get(0) + " on protocol version " + serverVersion.get(1));
|
||||
if(ElectrumServer.supportsBatching(serverVersion)) {
|
||||
@@ -230,13 +241,14 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
|
||||
private void showConnectionFailure(WorkerStateEvent failEvent) {
|
||||
Throwable e = failEvent.getSource().getException();
|
||||
log.error("Connection error", e);
|
||||
String reason = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
|
||||
if(e.getCause() != null && e.getCause() instanceof SSLHandshakeException) {
|
||||
reason = "SSL Handshake Error\n" + reason;
|
||||
}
|
||||
|
||||
testResults.setText("Could not connect:\n\n" + reason);
|
||||
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.EXCLAMATION_CIRCLE, Color.rgb(202, 18, 67)));
|
||||
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.EXCLAMATION_CIRCLE, "failure"));
|
||||
}
|
||||
|
||||
private void setupValidation() {
|
||||
@@ -328,11 +340,11 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
}
|
||||
}
|
||||
|
||||
private Glyph getGlyph(FontAwesome5.Glyph glyphName, Color color) {
|
||||
private Glyph getGlyph(FontAwesome5.Glyph glyphName, String styleClass) {
|
||||
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
|
||||
glyph.setFontSize(13);
|
||||
if(color != null) {
|
||||
glyph.setColor(color);
|
||||
glyph.setFontSize(12);
|
||||
if(styleClass != null) {
|
||||
glyph.getStyleClass().add(styleClass);
|
||||
}
|
||||
|
||||
return glyph;
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.sparrowwallet.sparrow.transaction;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.hummingbird.UR;
|
||||
import com.sparrowwallet.hummingbird.registry.RegistryType;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
@@ -15,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Device;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import com.sparrowwallet.sparrow.payjoin.Payjoin;
|
||||
import com.sparrowwallet.sparrow.payjoin.PayjoinReceiverException;
|
||||
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import javafx.application.Platform;
|
||||
@@ -39,17 +45,15 @@ import tornadofx.control.Fieldset;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import tornadofx.control.Form;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.URL;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class HeadersController extends TransactionFormController implements Initializable {
|
||||
public class HeadersController extends TransactionFormController implements Initializable, DynamicUpdate {
|
||||
private static final Logger log = LoggerFactory.getLogger(HeadersController.class);
|
||||
public static final String LOCKTIME_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
public static final String BLOCK_TIMESTAMP_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss ZZZ";
|
||||
@@ -121,7 +125,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
private CopyableLabel feeRate;
|
||||
|
||||
@FXML
|
||||
private Form blockchainForm;
|
||||
private DynamicForm blockchainForm;
|
||||
|
||||
@FXML
|
||||
private Label blockStatus;
|
||||
@@ -195,6 +199,9 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
@FXML
|
||||
private Button saveFinalButton;
|
||||
|
||||
@FXML
|
||||
private Button payjoinButton;
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
@@ -355,6 +362,12 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
saveFinalButton.visibleProperty().bind(broadcastButton.visibleProperty().not());
|
||||
broadcastButton.visibleProperty().bind(AppController.onlineProperty());
|
||||
|
||||
BitcoinURI payjoinURI = getPayjoinURI();
|
||||
boolean isPayjoinOriginalTx = payjoinURI != null && headersForm.getPsbt() != null && headersForm.getPsbt().getPsbtInputs().stream().noneMatch(PSBTInput::isFinalized);
|
||||
payjoinButton.managedProperty().bind(payjoinButton.visibleProperty());
|
||||
payjoinButton.visibleProperty().set(isPayjoinOriginalTx);
|
||||
broadcastButton.setDefaultButton(!isPayjoinOriginalTx);
|
||||
|
||||
blockchainForm.setVisible(false);
|
||||
signingWalletForm.setVisible(false);
|
||||
sigHashForm.setVisible(false);
|
||||
@@ -364,7 +377,6 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
broadcastButtonBox.setVisible(false);
|
||||
|
||||
if(headersForm.getBlockTransaction() != null) {
|
||||
blockchainForm.setVisible(true);
|
||||
updateBlockchainForm(headersForm.getBlockTransaction(), AppController.getCurrentBlockHeight());
|
||||
} else if(headersForm.getPsbt() != null) {
|
||||
PSBT psbt = headersForm.getPsbt();
|
||||
@@ -410,6 +422,8 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
}
|
||||
|
||||
blockchainForm.setDynamicUpdate(this);
|
||||
}
|
||||
|
||||
private void updateType() {
|
||||
@@ -467,6 +481,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
|
||||
private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) {
|
||||
signaturesForm.setVisible(false);
|
||||
blockchainForm.setVisible(true);
|
||||
|
||||
if(Sha256Hash.ZERO_HASH.equals(blockTransaction.getBlockHash()) && blockTransaction.getHeight() == 0 && headersForm.getSigningWallet() == null) {
|
||||
@@ -493,6 +508,8 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
indicator = (ConfirmationProgressIndicator)blockStatus.getGraphic();
|
||||
indicator.setConfirmations(confirmations);
|
||||
}
|
||||
} else {
|
||||
blockStatus.setGraphic(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,6 +553,24 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
}
|
||||
|
||||
private BitcoinURI getPayjoinURI() {
|
||||
if(headersForm.getPsbt() != null) {
|
||||
for(TransactionOutput txOutput : headersForm.getPsbt().getTransaction().getOutputs()) {
|
||||
try {
|
||||
Address address = txOutput.getScript().getToAddresses()[0];
|
||||
BitcoinURI bitcoinURI = AppController.getPayjoinURI(address);
|
||||
if(bitcoinURI != null) {
|
||||
return bitcoinURI;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static class BlockHeightContextMenu extends ContextMenu {
|
||||
public BlockHeightContextMenu(Sha256Hash blockHash) {
|
||||
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
|
||||
@@ -551,7 +586,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
private void updateTxId() {
|
||||
id.setText(headersForm.getTransaction().calculateTxId(false).toString());
|
||||
if(headersForm.getPsbt() != null && !(headersForm.getTransaction().hasScriptSigs() || headersForm.getTransaction().hasWitnesses())) {
|
||||
if(!headersForm.isTransactionFinalized()) {
|
||||
if(!id.getStyleClass().contains(UNFINALIZED_TXID_CLASS)) {
|
||||
id.getStyleClass().add(UNFINALIZED_TXID_CLASS);
|
||||
}
|
||||
@@ -585,8 +620,12 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
ToggleButton toggleButton = (ToggleButton)event.getSource();
|
||||
toggleButton.setSelected(false);
|
||||
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(headersForm.getPsbt().serialize());
|
||||
qrDisplayDialog.show();
|
||||
try {
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(RegistryType.CRYPTO_PSBT.toString(), headersForm.getPsbt().serialize());
|
||||
qrDisplayDialog.show();
|
||||
} catch(UR.URException e) {
|
||||
log.error("Error creating PSBT UR", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void scanPSBT(ActionEvent event) {
|
||||
@@ -779,6 +818,34 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
}
|
||||
|
||||
public void getPayjoinTransaction(ActionEvent event) {
|
||||
BitcoinURI payjoinURI = getPayjoinURI();
|
||||
if(payjoinURI == null) {
|
||||
throw new IllegalStateException("No valid Payjoin URI");
|
||||
}
|
||||
|
||||
try {
|
||||
Payjoin payjoin = new Payjoin(payjoinURI, headersForm.getSigningWallet(), headersForm.getPsbt());
|
||||
PSBT proposalPsbt = payjoin.requestPayjoinPSBT(true);
|
||||
EventManager.get().post(new ViewPSBTEvent(headersForm.getName() + " Payjoin", proposalPsbt));
|
||||
} catch(PayjoinReceiverException e) {
|
||||
AppController.showErrorDialog("Invalid Payjoin Transaction", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update() {
|
||||
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
|
||||
Sha256Hash txId = headersForm.getTransaction().getTxId();
|
||||
if(headersForm.getSigningWallet() != null && headersForm.getSigningWallet().getTransactions().containsKey(txId)) {
|
||||
blockTransaction = headersForm.getSigningWallet().getTransactions().get(txId);
|
||||
}
|
||||
|
||||
if(blockTransaction != null && AppController.getCurrentBlockHeight() != null) {
|
||||
updateBlockchainForm(blockTransaction, AppController.getCurrentBlockHeight());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void transactionChanged(TransactionChangedEvent event) {
|
||||
if(headersForm.getTransaction().equals(event.getTransaction())) {
|
||||
@@ -815,7 +882,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
@Subscribe
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
if(headersForm.getPsbt() != null && headersForm.getBlockTransaction() == null) {
|
||||
List<Wallet> availableWallets = event.getWallets().stream().filter(wallet -> wallet.canSign(headersForm.getPsbt())).collect(Collectors.toList());
|
||||
List<Wallet> availableWallets = event.getWallets().stream().filter(wallet -> wallet.canSign(headersForm.getPsbt())).sorted(new WalletSignComparator()).collect(Collectors.toList());
|
||||
Map<Wallet, Storage> availableWalletsMap = new LinkedHashMap<>(event.getWalletsMap());
|
||||
availableWalletsMap.keySet().retainAll(availableWallets);
|
||||
headersForm.getAvailableWallets().keySet().retainAll(availableWallets);
|
||||
@@ -939,7 +1006,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
@Subscribe
|
||||
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
|
||||
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null) {
|
||||
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null && headersForm.isTransactionFinalized()) {
|
||||
Sha256Hash txid = headersForm.getTransaction().getTxId();
|
||||
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash());
|
||||
transactionReferenceService.setOnSucceeded(successEvent -> {
|
||||
@@ -947,7 +1014,6 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
BlockTransaction blockTransaction = transactionMap.get(txid);
|
||||
if(blockTransaction != null) {
|
||||
headersForm.setBlockTransaction(blockTransaction);
|
||||
signaturesForm.setVisible(false);
|
||||
updateBlockchainForm(blockTransaction, AppController.getCurrentBlockHeight());
|
||||
}
|
||||
});
|
||||
@@ -994,4 +1060,18 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
futureBlockWarning.setVisible(AppController.getCurrentBlockHeight() != null && locktimeBlock.getValue() > event.getHeight());
|
||||
}
|
||||
}
|
||||
|
||||
private static class WalletSignComparator implements Comparator<Wallet> {
|
||||
private static final List<KeystoreSource> sourceOrder = List.of(KeystoreSource.SW_WATCH, KeystoreSource.HW_AIRGAPPED, KeystoreSource.HW_USB, KeystoreSource.SW_SEED);
|
||||
|
||||
@Override
|
||||
public int compare(Wallet wallet1, Wallet wallet2) {
|
||||
return getHighestSourceIndex(wallet2) - getHighestSourceIndex(wallet1);
|
||||
|
||||
}
|
||||
|
||||
private int getHighestSourceIndex(Wallet wallet) {
|
||||
return wallet.getKeystores().stream().map(keystore -> sourceOrder.indexOf(keystore.getSource())).mapToInt(v -> v).max().orElse(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
@@ -120,6 +121,11 @@ public class InputController extends TransactionFormController implements Initia
|
||||
TransactionInput txInput = inputForm.getTransactionInput();
|
||||
PSBTInput psbtInput = inputForm.getPsbtInput();
|
||||
|
||||
inputForm.signingWalletProperty().addListener((observable, oldValue, signingWallet) -> {
|
||||
updateInputLegendFromWallet(txInput, signingWallet);
|
||||
});
|
||||
updateInputLegendFromWallet(txInput, inputForm.getSigningWallet());
|
||||
|
||||
initializeInputFields(txInput, psbtInput);
|
||||
initializeScriptFields(txInput, psbtInput);
|
||||
initializeStatusFields(txInput, psbtInput);
|
||||
@@ -132,9 +138,27 @@ public class InputController extends TransactionFormController implements Initia
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeInputFields(TransactionInput txInput, PSBTInput psbtInput) {
|
||||
inputFieldset.setText("Input #" + txInput.getIndex());
|
||||
private String getLegendText(TransactionInput txInput) {
|
||||
return "Input #" + txInput.getIndex();
|
||||
}
|
||||
|
||||
private void updateInputLegendFromWallet(TransactionInput txInput, Wallet signingWallet) {
|
||||
String baseText = getLegendText(txInput);
|
||||
if(signingWallet != null) {
|
||||
if(inputForm.isWalletTxo()) {
|
||||
inputFieldset.setText(baseText + " - " + signingWallet.getName());
|
||||
inputFieldset.setIcon(TransactionDiagram.getTxoGlyph());
|
||||
} else {
|
||||
inputFieldset.setText(baseText + " - Payjoin");
|
||||
inputFieldset.setIcon(TransactionDiagram.getPayjoinGlyph());
|
||||
}
|
||||
} else {
|
||||
inputFieldset.setText(baseText);
|
||||
inputFieldset.setIcon(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeInputFields(TransactionInput txInput, PSBTInput psbtInput) {
|
||||
outpoint.managedProperty().bind(outpoint.visibleProperty());
|
||||
linkedOutpoint.managedProperty().bind(linkedOutpoint.visibleProperty());
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ public class InputForm extends IndexedTransactionForm {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isWalletTxo() {
|
||||
TransactionInput txInput = getTransactionInput();
|
||||
return getSigningWallet() != null && getSigningWallet().getWalletTxos().keySet().stream().anyMatch(ref -> ref.getHash().equals(txInput.getOutpoint().getHash()) && ref.getIndex() == txInput.getOutpoint().getIndex());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getContents() throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("input.fxml"));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.transaction;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.protocol.NonStandardScriptException;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionInput;
|
||||
@@ -106,10 +105,10 @@ public class OutputController extends TransactionFormController implements Initi
|
||||
private void updateOutputLegendFromWallet(TransactionOutput txOutput, Wallet signingWallet) {
|
||||
String baseText = getLegendText(txOutput);
|
||||
if(signingWallet != null) {
|
||||
if(signingWallet.getWalletOutputScripts(KeyPurpose.RECEIVE).containsKey(txOutput.getScript())) {
|
||||
if(outputForm.isWalletConsolidation()) {
|
||||
outputFieldset.setText(baseText + " - Consolidation");
|
||||
outputFieldset.setIcon(TransactionDiagram.getConsolidationGlyph());
|
||||
} else if(signingWallet.getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(txOutput.getScript())) {
|
||||
} else if(outputForm.isWalletChange()) {
|
||||
outputFieldset.setText(baseText + " - Change");
|
||||
outputFieldset.setIcon(TransactionDiagram.getChangeGlyph());
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.transaction;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTOutput;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
@@ -30,6 +31,18 @@ public class OutputForm extends IndexedTransactionForm {
|
||||
return psbtOutput;
|
||||
}
|
||||
|
||||
public boolean isWalletConsolidation() {
|
||||
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.RECEIVE).containsKey(getTransactionOutput().getScript()));
|
||||
}
|
||||
|
||||
public boolean isWalletChange() {
|
||||
return (getSigningWallet() != null && getSigningWallet().getWalletOutputScripts(KeyPurpose.CHANGE).containsKey(getTransactionOutput().getScript()));
|
||||
}
|
||||
|
||||
public boolean isWalletPayment() {
|
||||
return getSigningWallet() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Node getContents() throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("output.fxml"));
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.TransactionTabData;
|
||||
import com.sparrowwallet.sparrow.control.TransactionDiagram;
|
||||
import com.sparrowwallet.sparrow.control.TransactionHexArea;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
@@ -17,11 +18,10 @@ import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.TreeCell;
|
||||
import javafx.scene.control.TreeItem;
|
||||
import javafx.scene.control.TreeView;
|
||||
import javafx.scene.control.cell.TextFieldTreeCell;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.util.StringConverter;
|
||||
import org.controlsfx.control.MasterDetailPane;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -125,17 +125,46 @@ public class TransactionController implements Initializable {
|
||||
rootItem.getChildren().add(outputsItem);
|
||||
txtree.setRoot(rootItem);
|
||||
|
||||
txtree.setCellFactory(p -> new TextFieldTreeCell<>(new StringConverter<TransactionForm>() {
|
||||
txtree.setCellFactory(tc -> new TreeCell<>() {
|
||||
@Override
|
||||
public String toString(TransactionForm transactionForm) {
|
||||
return transactionForm.toString();
|
||||
}
|
||||
protected void updateItem(TransactionForm form, boolean empty) {
|
||||
super.updateItem(form, empty);
|
||||
|
||||
@Override
|
||||
public TransactionForm fromString(String string) {
|
||||
throw new IllegalStateException("No editing");
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
|
||||
if(form != null) {
|
||||
setText(form.toString());
|
||||
|
||||
if(form.getSigningWallet() != null) {
|
||||
if(form instanceof InputForm) {
|
||||
InputForm inputForm = (InputForm)form;
|
||||
if(inputForm.isWalletTxo()) {
|
||||
setGraphic(TransactionDiagram.getTxoGlyph());
|
||||
} else {
|
||||
setGraphic(TransactionDiagram.getPayjoinGlyph());
|
||||
}
|
||||
}
|
||||
if(form instanceof OutputForm) {
|
||||
OutputForm outputForm = (OutputForm)form;
|
||||
if(outputForm.isWalletConsolidation()) {
|
||||
setGraphic(TransactionDiagram.getConsolidationGlyph());
|
||||
} else if(outputForm.isWalletChange()) {
|
||||
setGraphic(TransactionDiagram.getChangeGlyph());
|
||||
} else {
|
||||
setGraphic(TransactionDiagram.getPaymentGlyph());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
txdata.signingWalletProperty().addListener((observable, oldValue, newValue) -> {
|
||||
txtree.refresh();
|
||||
});
|
||||
|
||||
txtree.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedItem) -> {
|
||||
TransactionForm transactionForm = selectedItem.getValue();
|
||||
|
||||
@@ -107,6 +107,10 @@ public abstract class TransactionForm {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isTransactionFinalized() {
|
||||
return getPsbt() == null || getTransaction().hasScriptSigs() || getTransaction().hasWitnesses();
|
||||
}
|
||||
|
||||
public abstract Node getContents() throws IOException;
|
||||
|
||||
public abstract TransactionView getView();
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class Bytewords {
|
||||
public static final String BYTEWORDS = "ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbrewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadaysdelidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfactfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgiftgirlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhutsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjurykeepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlistlimplionlogoloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneednewsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolposepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesagascarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoiltombtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswallwandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszestzinczonezoomzero";
|
||||
private static final List<String> bytewordsList;
|
||||
private static final List<String> minimalBytewordsList;
|
||||
|
||||
static {
|
||||
bytewordsList = getBytewords();
|
||||
minimalBytewordsList = getMinimalBytewords();
|
||||
}
|
||||
|
||||
public enum Style {
|
||||
STANDARD, URI, MINIMAL
|
||||
}
|
||||
|
||||
public static int getEncodedLength(int length, Style style) {
|
||||
if(style == Style.STANDARD || style == Style.URI) {
|
||||
return length * 4 + (length - 1);
|
||||
}
|
||||
|
||||
return length * 2;
|
||||
}
|
||||
|
||||
public static String encode(byte[] data, Style style) {
|
||||
if(style == Style.STANDARD) {
|
||||
return encode(data, " ");
|
||||
}
|
||||
if(style == Style.URI) {
|
||||
return encode(data, "-");
|
||||
}
|
||||
|
||||
return encodeMinimal(data);
|
||||
}
|
||||
|
||||
public static byte[] decode(String encoded, Style style) {
|
||||
if(style == Style.STANDARD) {
|
||||
return decode(encoded, " ");
|
||||
}
|
||||
if(style == Style.URI) {
|
||||
return decode(encoded, "-");
|
||||
}
|
||||
|
||||
return decodeMinimal(encoded);
|
||||
}
|
||||
|
||||
private static String encode(byte[] data, String separator) {
|
||||
byte[] dataAndChecksum = appendChecksum(data);
|
||||
List<String> words = IntStream.range(0, dataAndChecksum.length).map(index -> dataAndChecksum[index] & 0xFF).mapToObj(Bytewords::getByteword).collect(Collectors.toList());
|
||||
StringJoiner joiner = new StringJoiner(separator);
|
||||
words.forEach(joiner::add);
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
private static String encodeMinimal(byte[] data) {
|
||||
byte[] dataAndChecksum = appendChecksum(data);
|
||||
List<String> words = IntStream.range(0, dataAndChecksum.length).map(index -> dataAndChecksum[index] & 0xFF).mapToObj(Bytewords::getMinimalByteword).collect(Collectors.toList());
|
||||
StringBuilder buffer = new StringBuilder();
|
||||
words.forEach(buffer::append);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
private static byte[] decode(String encoded, String separator) {
|
||||
String[] words = encoded.split(separator);
|
||||
byte[] data = toByteArray(Arrays.stream(words).mapToInt(word -> getBytewords().indexOf(word)));
|
||||
return stripChecksum(data);
|
||||
}
|
||||
|
||||
private static byte[] decodeMinimal(String encoded) {
|
||||
List<String> words = splitStringBySize(encoded, 2);
|
||||
byte[] data = toByteArray(words.stream().mapToInt(word -> getMinimalBytewords().indexOf(word)));
|
||||
return stripChecksum(data);
|
||||
}
|
||||
|
||||
private static byte[] appendChecksum(byte[] data) {
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(data);
|
||||
|
||||
ByteBuffer checksum = ByteBuffer.allocate(Long.BYTES);
|
||||
checksum.putLong(crc.getValue());
|
||||
|
||||
byte[] result = new byte[data.length + 4];
|
||||
System.arraycopy(data, 0, result, 0, data.length);
|
||||
System.arraycopy(checksum.array(), 4, result, data.length, 4);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] stripChecksum(byte[] dataAndChecksum) {
|
||||
byte[] data = Arrays.copyOfRange(dataAndChecksum, 0, dataAndChecksum.length - 4);
|
||||
byte[] checksum = Arrays.copyOfRange(dataAndChecksum, dataAndChecksum.length - 4, dataAndChecksum.length);
|
||||
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(data);
|
||||
|
||||
ByteBuffer calculedChecksum = ByteBuffer.allocate(Long.BYTES);
|
||||
calculedChecksum.putLong(crc.getValue());
|
||||
if(!Arrays.equals(Arrays.copyOfRange(calculedChecksum.array(), 4, 8), checksum)) {
|
||||
throw new InvalidChecksumException("Invalid checksum");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static String getByteword(int dataByte) {
|
||||
return bytewordsList.get(dataByte);
|
||||
}
|
||||
|
||||
private static String getMinimalByteword(int dataByte) {
|
||||
return minimalBytewordsList.get(dataByte);
|
||||
}
|
||||
|
||||
private static List<String> getBytewords() {
|
||||
return IntStream.range(0, 256).mapToObj(i -> BYTEWORDS.substring(i * 4, (i * 4) + 4)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private static List<String> getMinimalBytewords() {
|
||||
return IntStream.range(0, 256).mapToObj(i -> Character.toString(BYTEWORDS.charAt(i * 4)) + BYTEWORDS.charAt((i * 4) + 3)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public static byte[] toByteArray(IntStream stream) {
|
||||
return stream.collect(ByteArrayOutputStream::new, (baos, i) -> baos.write((byte) i),
|
||||
(baos1, baos2) -> baos1.write(baos2.toByteArray(), 0, baos2.size()))
|
||||
.toByteArray();
|
||||
}
|
||||
|
||||
private static List<String> splitStringBySize(String str, int size) {
|
||||
List<String> split = new ArrayList<>();
|
||||
for(int i = 0; i < str.length() / size; i++) {
|
||||
split.add(str.substring(i * size, Math.min((i + 1) * size, str.length())));
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
public static class InvalidChecksumException extends RuntimeException {
|
||||
public InvalidChecksumException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur;
|
||||
|
||||
public enum ResultType {
|
||||
SUCCESS, FAILURE;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur;
|
||||
|
||||
import co.nstant.in.cbor.CborBuilder;
|
||||
import co.nstant.in.cbor.CborDecoder;
|
||||
import co.nstant.in.cbor.CborEncoder;
|
||||
import co.nstant.in.cbor.CborException;
|
||||
import co.nstant.in.cbor.model.ByteString;
|
||||
import co.nstant.in.cbor.model.DataItem;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class UR {
|
||||
public static final String UR_PREFIX = "ur";
|
||||
public static final String BYTES_TYPE = "bytes";
|
||||
public static final String CRYPTO_PSBT_TYPE = "crypto-psbt";
|
||||
|
||||
private final String type;
|
||||
private final byte[] data;
|
||||
|
||||
public UR(String type, byte[] data) throws InvalidTypeException {
|
||||
if(!isURType(type)) {
|
||||
throw new InvalidTypeException("Invalid UR type: " + type);
|
||||
}
|
||||
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public byte[] getCbor() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public byte[] toBytes() throws InvalidTypeException, CborException {
|
||||
if(!BYTES_TYPE.equals(getType())) {
|
||||
throw new InvalidTypeException("Not a " + BYTES_TYPE + " type");
|
||||
}
|
||||
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(getCbor());
|
||||
List<DataItem> dataItems = new CborDecoder(bais).decode();
|
||||
return ((ByteString)dataItems.get(0)).getBytes();
|
||||
}
|
||||
|
||||
public static boolean isURType(String type) {
|
||||
for(char c : type.toCharArray()) {
|
||||
if('a' <= c && c <= 'z') {
|
||||
return true;
|
||||
}
|
||||
if('0' <= c && c <= '9') {
|
||||
return true;
|
||||
}
|
||||
if(c == '-') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static UR fromBytes(byte[] data) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
new CborEncoder(baos).encode(new CborBuilder()
|
||||
.add(data)
|
||||
.build());
|
||||
byte[] cbor = baos.toByteArray();
|
||||
|
||||
return new UR("bytes", cbor);
|
||||
} catch(InvalidTypeException | CborException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return UREncoder.encode(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if(this == o) {
|
||||
return true;
|
||||
}
|
||||
if(o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
UR ur = (UR) o;
|
||||
return type.equals(ur.type) &&
|
||||
Arrays.equals(data, ur.data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = Objects.hash(type);
|
||||
result = 31 * result + Arrays.hashCode(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static class URException extends Exception {
|
||||
public URException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class InvalidTypeException extends URException {
|
||||
public InvalidTypeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class InvalidSchemeException extends URException {
|
||||
public InvalidSchemeException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class InvalidPathLengthException extends URException {
|
||||
public InvalidPathLengthException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static class InvalidSequenceComponentException extends URException {
|
||||
public InvalidSequenceComponentException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur;
|
||||
|
||||
import co.nstant.in.cbor.CborException;
|
||||
import com.sparrowwallet.sparrow.ur.fountain.FountainDecoder;
|
||||
import com.sparrowwallet.sparrow.ur.fountain.FountainEncoder;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class URDecoder {
|
||||
private static final Pattern SEQUENCE_COMPONENT_PATTERN = Pattern.compile("(\\d+)-(\\d+)");
|
||||
|
||||
private final FountainDecoder fountainDecoder;
|
||||
|
||||
private String expectedType;
|
||||
private Result result;
|
||||
|
||||
public URDecoder() {
|
||||
this.fountainDecoder = new FountainDecoder();
|
||||
}
|
||||
|
||||
public int getExpectedPartCount() {
|
||||
return fountainDecoder.getExpectedPartCount();
|
||||
}
|
||||
|
||||
public Set<Integer> getReceivedPartIndexes() {
|
||||
return fountainDecoder.getRecievedPartIndexes();
|
||||
}
|
||||
|
||||
public Set<Integer> getLastPartIndexes() {
|
||||
return fountainDecoder.getLastPartIndexes();
|
||||
}
|
||||
|
||||
public int getProcessedPartsCount() {
|
||||
return fountainDecoder.getProcessedPartsCount();
|
||||
}
|
||||
|
||||
public double getEstimatedPercentComplete() {
|
||||
return fountainDecoder.getEstimatedPercentComplete();
|
||||
}
|
||||
|
||||
public Result getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
public static UR decode(String string) throws UR.URException {
|
||||
ParsedURString parsedURString = parse(string);
|
||||
|
||||
if(parsedURString.components.length < 1) {
|
||||
throw new UR.InvalidPathLengthException("Invalid path length");
|
||||
}
|
||||
|
||||
String body = parsedURString.components[0];
|
||||
return decode(parsedURString.type, body);
|
||||
}
|
||||
|
||||
public static UR decode(String type, String body) throws UR.InvalidTypeException {
|
||||
byte[] cbor = Bytewords.decode(body, Bytewords.Style.MINIMAL);
|
||||
return new UR(type, cbor);
|
||||
}
|
||||
|
||||
public boolean receivePart(String string) {
|
||||
try {
|
||||
// Don't process the part if we're already done
|
||||
if(getResult() != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't continue if this part doesn't validate
|
||||
ParsedURString parsedURString = parse(string);
|
||||
if(!validatePart(parsedURString.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this is a single-part UR then we're done
|
||||
if(parsedURString.components.length == 1) {
|
||||
String body = parsedURString.components[0];
|
||||
result = new Result(ResultType.SUCCESS, decode(parsedURString.type, body), null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Multi-part URs must have two path components: seq/fragment
|
||||
if(parsedURString.components.length != 2) {
|
||||
throw new UR.InvalidPathLengthException("Invalid path length");
|
||||
}
|
||||
|
||||
String seq = parsedURString.components[0];
|
||||
String fragment = parsedURString.components[1];
|
||||
|
||||
// Parse the sequence component and the fragment, and
|
||||
// make sure they agree.
|
||||
Matcher matcher = SEQUENCE_COMPONENT_PATTERN.matcher(seq);
|
||||
if(matcher.matches()) {
|
||||
int seqNum = Integer.parseInt(matcher.group(1));
|
||||
int seqLen = Integer.parseInt(matcher.group(2));
|
||||
|
||||
byte[] cbor = Bytewords.decode(fragment, Bytewords.Style.MINIMAL);
|
||||
FountainEncoder.Part part = FountainEncoder.Part.fromCborBytes(cbor);
|
||||
|
||||
if(seqNum != part.getSeqNum() || seqLen != part.getSeqLen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!fountainDecoder.receivePart(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(fountainDecoder.getResult() == null) {
|
||||
//Not done yet
|
||||
} else if(fountainDecoder.getResult().type == ResultType.SUCCESS) {
|
||||
result = new Result(ResultType.SUCCESS, new UR(parsedURString.type, fountainDecoder.getResult().data), null);
|
||||
} else if(fountainDecoder.getResult().type == ResultType.FAILURE) {
|
||||
result = new Result(ResultType.FAILURE, null, fountainDecoder.getResult().error);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
throw new UR.InvalidSequenceComponentException("Invalid sequence " + seq);
|
||||
}
|
||||
} catch(UR.URException | CborException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validatePart(String type) {
|
||||
if(expectedType == null) {
|
||||
if(!UR.isURType(type)) {
|
||||
return false;
|
||||
}
|
||||
expectedType = type;
|
||||
} else {
|
||||
return expectedType.equals(type);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static ParsedURString parse(String string) throws UR.URException {
|
||||
// Don't consider case
|
||||
String lowercased = string.toLowerCase();
|
||||
|
||||
// Validate URI scheme
|
||||
if(!lowercased.startsWith("ur:")) {
|
||||
throw new UR.InvalidSchemeException("Invalid scheme");
|
||||
}
|
||||
String path = lowercased.substring(3);
|
||||
|
||||
// Split the remainder into path components
|
||||
String[] components = path.split("/");
|
||||
|
||||
// Make sure there are at least two path components
|
||||
if(components.length <= 1) {
|
||||
throw new UR.InvalidPathLengthException("Invalid path length");
|
||||
}
|
||||
|
||||
// Validate the type
|
||||
String type = components[0];
|
||||
if(!UR.isURType(type)) {
|
||||
throw new UR.InvalidTypeException("Invalid type: " + type);
|
||||
}
|
||||
|
||||
return new ParsedURString(type, Arrays.copyOfRange(components, 1, components.length));
|
||||
}
|
||||
|
||||
private static class ParsedURString {
|
||||
public final String type;
|
||||
public final String[] components;
|
||||
|
||||
public ParsedURString(String type, String[] components) {
|
||||
this.type = type;
|
||||
this.components = components;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Result {
|
||||
public final ResultType type;
|
||||
public final UR ur;
|
||||
public final String error;
|
||||
|
||||
public Result(ResultType type, UR ur, String error) {
|
||||
this.type = type;
|
||||
this.ur = ur;
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur;
|
||||
|
||||
import com.sparrowwallet.sparrow.ur.fountain.FountainEncoder;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class UREncoder {
|
||||
private final UR ur;
|
||||
private final FountainEncoder fountainEncoder;
|
||||
|
||||
public UREncoder(UR ur, int maxFragmentLen, int minFragmentLen, long firstSeqNum) {
|
||||
this.ur = ur;
|
||||
this.fountainEncoder = new FountainEncoder(ur.getCbor(), maxFragmentLen, minFragmentLen, firstSeqNum);
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return fountainEncoder.isComplete();
|
||||
}
|
||||
|
||||
public boolean isSinglePart() {
|
||||
return fountainEncoder.isSinglePart();
|
||||
}
|
||||
|
||||
public String nextPart() {
|
||||
FountainEncoder.Part part = fountainEncoder.nextPart();
|
||||
if(isSinglePart()) {
|
||||
return encode(ur);
|
||||
} else {
|
||||
return encodePart(ur.getType(), part);
|
||||
}
|
||||
}
|
||||
|
||||
public long getSeqNum() {
|
||||
return fountainEncoder.getSeqNum();
|
||||
}
|
||||
|
||||
public int getSeqLen() {
|
||||
return fountainEncoder.getSeqLen();
|
||||
}
|
||||
|
||||
public List<Integer> getPartIndexes() {
|
||||
return fountainEncoder.getPartIndexes();
|
||||
}
|
||||
|
||||
public static String encode(UR ur) {
|
||||
String encoded = Bytewords.encode(ur.getCbor(), Bytewords.Style.MINIMAL);
|
||||
return encodeUR(ur.getType(), encoded);
|
||||
}
|
||||
|
||||
private static String encodeUR(String... pathComponents) {
|
||||
return encodeURI(UR.UR_PREFIX, pathComponents);
|
||||
}
|
||||
|
||||
private static String encodeURI(String scheme, String... pathComponents) {
|
||||
StringJoiner joiner = new StringJoiner("/");
|
||||
Arrays.stream(pathComponents).forEach(joiner::add);
|
||||
String path = joiner.toString();
|
||||
|
||||
return scheme + ":" + path;
|
||||
}
|
||||
|
||||
private static String encodePart(String type, FountainEncoder.Part part) {
|
||||
String seq = part.getSeqNum() + "-" + part.getSeqLen();
|
||||
String body = Bytewords.encode(part.toCborBytes(), Bytewords.Style.MINIMAL);
|
||||
return encodeUR(type, seq, body);
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur.fountain;
|
||||
|
||||
import com.sparrowwallet.sparrow.ur.ResultType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
import static com.sparrowwallet.sparrow.ur.fountain.FountainUtils.chooseFragments;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class FountainDecoder {
|
||||
private static final Logger log = LoggerFactory.getLogger(FountainDecoder.class);
|
||||
|
||||
private final Set<Integer> recievedPartIndexes = new TreeSet<>();
|
||||
private Set<Integer> lastPartIndexes;
|
||||
private int processedPartsCount = 0;
|
||||
private Result result;
|
||||
private long checksum;
|
||||
|
||||
private Set<Integer> expectedPartIndexes;
|
||||
private int expectedFragmentLen;
|
||||
private int expectedMessageLen;
|
||||
private long expectedChecksum;
|
||||
|
||||
private final Map<List<Integer>, Part> simpleParts = new HashMap<>();
|
||||
private Map<List<Integer>, Part> mixedParts = new HashMap<>();
|
||||
private final List<Part> queuedParts = new ArrayList<>();
|
||||
|
||||
public int getExpectedPartCount() {
|
||||
return expectedPartIndexes.size();
|
||||
}
|
||||
|
||||
public Set<Integer> getRecievedPartIndexes() {
|
||||
return recievedPartIndexes;
|
||||
}
|
||||
|
||||
public Set<Integer> getLastPartIndexes() {
|
||||
return lastPartIndexes;
|
||||
}
|
||||
|
||||
public int getProcessedPartsCount() {
|
||||
return processedPartsCount;
|
||||
}
|
||||
|
||||
public double getEstimatedPercentComplete() {
|
||||
double estimatedInputParts = (double)getExpectedPartCount() * 1.75;
|
||||
return Math.min(0.99, (double)processedPartsCount / estimatedInputParts);
|
||||
}
|
||||
|
||||
public Result getResult() {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static class Part {
|
||||
private final List<Integer> partIndexes;
|
||||
private final byte[] data;
|
||||
|
||||
private int getIndex() {
|
||||
return partIndexes.get(0);
|
||||
}
|
||||
|
||||
Part(FountainEncoder.Part part) {
|
||||
this.partIndexes = chooseFragments(part.getSeqNum(), part.getSeqLen(), part.getChecksum());
|
||||
this.data = part.getData();
|
||||
}
|
||||
|
||||
Part(List<Integer> indexes, byte[] data) {
|
||||
this.partIndexes = indexes;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public boolean isSimple() {
|
||||
return partIndexes.size() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Result {
|
||||
public final ResultType type;
|
||||
public final byte[] data;
|
||||
public final String error;
|
||||
|
||||
public Result(ResultType type, byte[] data, String error) {
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean receivePart(FountainEncoder.Part encoderPart) {
|
||||
// Don't process the part if we're already done
|
||||
if(result != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't continue if this part doesn't validate
|
||||
if(!validatePart(encoderPart)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add this part to the queue
|
||||
Part part = new Part(encoderPart);
|
||||
lastPartIndexes = new HashSet<>(part.partIndexes);
|
||||
enqueue(part);
|
||||
|
||||
// Process the queue until we're done or the queue is empty
|
||||
while(result == null && !queuedParts.isEmpty()) {
|
||||
processQueueItem();
|
||||
}
|
||||
|
||||
// Keep track of how many parts we've processed
|
||||
processedPartsCount += 1;
|
||||
//printPartEnd();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void enqueue(Part part) {
|
||||
queuedParts.add(part);
|
||||
}
|
||||
|
||||
private void printPartEnd() {
|
||||
int percent = (int)Math.round(getEstimatedPercentComplete() * 100);
|
||||
log.debug("processed: " + processedPartsCount + " expected: " + getExpectedPartCount() + " received: " + recievedPartIndexes.size() + " percent: " + percent + "%");
|
||||
}
|
||||
|
||||
private void printPart(Part part) {
|
||||
List<Integer> sorted = part.partIndexes.stream().sorted().collect(Collectors.toList());
|
||||
log.debug("part indexes: " + sorted);
|
||||
}
|
||||
|
||||
private void printState() {
|
||||
List<Integer> sortedReceived = recievedPartIndexes.stream().sorted().collect(Collectors.toList());
|
||||
List<List<Integer>> mixed = mixedParts.keySet().stream().map(list -> {
|
||||
list.sort(Comparator.naturalOrder());
|
||||
return list;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
log.debug("parts: " + getExpectedPartCount() + ", received: " + sortedReceived + ", mixed: " + mixed + ", queued: " + queuedParts.size() + ", result: " + result);
|
||||
}
|
||||
|
||||
private void processQueueItem() {
|
||||
Part part = queuedParts.remove(0);
|
||||
|
||||
//printPart(part);
|
||||
|
||||
if(part.isSimple()) {
|
||||
processSimplePart(part);
|
||||
} else {
|
||||
processMixedPart(part);
|
||||
}
|
||||
|
||||
//printState();
|
||||
}
|
||||
|
||||
private void reduceMixed(Part by) {
|
||||
// Reduce all the current mixed parts by the given part
|
||||
List<Part> reducedParts = mixedParts.values().stream().map(part -> reducePart(part, by)).collect(Collectors.toList());
|
||||
|
||||
// Collect all the remaining mixed parts
|
||||
Map<List<Integer>, Part> newMixed = new HashMap<>();
|
||||
reducedParts.forEach(reducedPart -> {
|
||||
// If this reduced part is now simple
|
||||
if(reducedPart.isSimple()) {
|
||||
// Add it to the queue
|
||||
enqueue(reducedPart);
|
||||
} else {
|
||||
// Otherwise, add it to the list of current mixed parts
|
||||
newMixed.put(reducedPart.partIndexes, reducedPart);
|
||||
}
|
||||
});
|
||||
|
||||
mixedParts = newMixed;
|
||||
}
|
||||
|
||||
// Reduce part `a` by part `b`
|
||||
private Part reducePart(Part a, Part b) {
|
||||
// If the fragments mixed into `b` are a strict (proper) subset of those in `a`...
|
||||
if(a.partIndexes.containsAll(b.partIndexes)) {
|
||||
// The new fragments in the revised part are `a` - `b`.
|
||||
List<Integer> newIndexes = new ArrayList<>(a.partIndexes);
|
||||
newIndexes.removeAll(b.partIndexes);
|
||||
|
||||
// The new data in the revised part are `a` XOR `b`
|
||||
byte[] newdata = FountainEncoder.xor(a.data, b.data);
|
||||
return new Part(newIndexes, newdata);
|
||||
} else {
|
||||
// `a` is not reducable by `b`, so return a
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
private void processSimplePart(Part part) {
|
||||
// Don't process duplicate parts
|
||||
Integer fragmentIndex = part.partIndexes.get(0);
|
||||
if(recievedPartIndexes.contains(fragmentIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record this part
|
||||
simpleParts.put(part.partIndexes, part);
|
||||
recievedPartIndexes.add(fragmentIndex);
|
||||
|
||||
// If we've received all the parts
|
||||
if(recievedPartIndexes.equals(expectedPartIndexes)) {
|
||||
// Reassemble the message from its fragments
|
||||
List<Part> sortedParts = simpleParts.values().stream().sorted(Comparator.comparingInt(Part::getIndex)).collect(Collectors.toList());
|
||||
List<byte[]> fragments = sortedParts.stream().map(part1 -> part1.data).collect(Collectors.toList());
|
||||
byte[] message = joinFragments(fragments, expectedMessageLen);
|
||||
|
||||
// Verify the message checksum and note success or failure
|
||||
CRC32 crc32 = new CRC32();
|
||||
crc32.update(message);
|
||||
checksum = crc32.getValue();
|
||||
|
||||
if(checksum == expectedChecksum) {
|
||||
result = new Result(ResultType.SUCCESS, message, null);
|
||||
} else {
|
||||
result = new Result(ResultType.FAILURE, null, "Invalid checksum");
|
||||
}
|
||||
} else {
|
||||
// Reduce all the mixed parts by this part
|
||||
reduceMixed(part);
|
||||
}
|
||||
}
|
||||
|
||||
private void processMixedPart(Part part) {
|
||||
// Don't process duplicate parts
|
||||
if(mixedParts.containsKey(part.partIndexes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reduce this part by all the others
|
||||
List<Part> allParts = new ArrayList<>(simpleParts.values());
|
||||
allParts.addAll(mixedParts.values());
|
||||
Part p = allParts.stream().reduce(part, this::reducePart);
|
||||
|
||||
// If the part is now simple
|
||||
if(p.isSimple()) {
|
||||
// Add it to the queue
|
||||
enqueue(p);
|
||||
} else {
|
||||
// Reduce all the mixed parts by this one
|
||||
reduceMixed(p);
|
||||
// Record this new mixed part
|
||||
mixedParts.put(p.partIndexes, p);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validatePart(FountainEncoder.Part part) {
|
||||
// If this is the first part we've seen
|
||||
if(expectedPartIndexes == null) {
|
||||
// Record the things that all the other parts we see will have to match to be valid.
|
||||
expectedPartIndexes = IntStream.range(0, part.getSeqLen()).boxed().collect(Collectors.toSet());
|
||||
expectedMessageLen = part.getMessageLen();
|
||||
expectedChecksum = part.getChecksum();
|
||||
expectedFragmentLen = part.getData().length;
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return getExpectedPartCount() == part.getSeqLen() && expectedMessageLen == part.getMessageLen() && expectedChecksum == part.getChecksum() && expectedFragmentLen == part.getData().length;
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] joinFragments(List<byte[]> fragments, int messageLen) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
fragments.forEach(baos::writeBytes);
|
||||
byte[] message = baos.toByteArray();
|
||||
|
||||
byte[] unpaddedMessage = new byte[messageLen];
|
||||
System.arraycopy(message, 0, unpaddedMessage, 0, messageLen);
|
||||
|
||||
return unpaddedMessage;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur.fountain;
|
||||
|
||||
import co.nstant.in.cbor.CborBuilder;
|
||||
import co.nstant.in.cbor.CborDecoder;
|
||||
import co.nstant.in.cbor.CborEncoder;
|
||||
import co.nstant.in.cbor.CborException;
|
||||
import co.nstant.in.cbor.model.Array;
|
||||
import co.nstant.in.cbor.model.ByteString;
|
||||
import co.nstant.in.cbor.model.DataItem;
|
||||
import co.nstant.in.cbor.model.UnsignedInteger;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
import static com.sparrowwallet.sparrow.ur.fountain.FountainUtils.chooseFragments;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class FountainEncoder {
|
||||
private final int messageLen;
|
||||
private final long checksum;
|
||||
private final int fragmentLen;
|
||||
private final List<byte[]> fragments;
|
||||
private final int seqLen;
|
||||
private List<Integer> partIndexes;
|
||||
private long seqNum;
|
||||
|
||||
public FountainEncoder(byte[] message, int maxFragmentLen, int minFragmentLen, long firstSeqNum) {
|
||||
if(message.length >= Integer.MAX_VALUE) {
|
||||
throw new IllegalArgumentException("Message too long");
|
||||
}
|
||||
|
||||
this.messageLen = message.length;
|
||||
|
||||
CRC32 crc32 = new CRC32();
|
||||
crc32.update(message);
|
||||
this.checksum = crc32.getValue();
|
||||
|
||||
this.fragmentLen = findNominalFragmentLength(messageLen, minFragmentLen, maxFragmentLen);
|
||||
this.fragments = partitionMessage(message, fragmentLen);
|
||||
this.seqLen = fragments.size();
|
||||
this.seqNum = firstSeqNum;
|
||||
}
|
||||
|
||||
public Part nextPart() {
|
||||
seqNum += 1;
|
||||
partIndexes = chooseFragments(seqNum, seqLen, checksum);
|
||||
byte[] mixed = mix(partIndexes);
|
||||
return new Part(seqNum, seqLen, messageLen, checksum, mixed);
|
||||
}
|
||||
|
||||
private byte[] mix(List<Integer> partIndexes) {
|
||||
return partIndexes.stream().reduce(new byte[fragmentLen], (result, index) -> xor(fragments.get(index), result), FountainEncoder::xor);
|
||||
}
|
||||
|
||||
public static byte[] xor(byte[] a, byte[] b) {
|
||||
byte[] result = new byte[a.length];
|
||||
for (int i = 0; i < result.length; i++) {
|
||||
result[i] = (byte) (((int) a[i]) ^ ((int) b[i]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return seqNum >= seqLen;
|
||||
}
|
||||
|
||||
public boolean isSinglePart() {
|
||||
return seqLen == 1;
|
||||
}
|
||||
|
||||
public long getSeqNum() {
|
||||
return seqNum;
|
||||
}
|
||||
|
||||
public int getSeqLen() {
|
||||
return seqLen;
|
||||
}
|
||||
|
||||
public List<Integer> getPartIndexes() {
|
||||
return partIndexes;
|
||||
}
|
||||
|
||||
static List<byte[]> partitionMessage(byte[] message, int fragmentLen) {
|
||||
int fragmentCount = (int)Math.ceil(message.length / (double)fragmentLen);
|
||||
List<byte[]> fragments = new ArrayList<>();
|
||||
|
||||
int start = 0;
|
||||
for(int i = 0; i < fragmentCount; i++) {
|
||||
fragments.add(Arrays.copyOfRange(message, start, start + fragmentLen));
|
||||
start += fragmentLen;
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
static int findNominalFragmentLength(int messageLen, int minFragmentLen, int maxFragmentLen) {
|
||||
int maxFragmentCount = messageLen / minFragmentLen;
|
||||
int fragmentLen = 0;
|
||||
for(int fragmentCount = 1; fragmentCount <= maxFragmentCount; fragmentCount++) {
|
||||
fragmentLen = (int)Math.ceil((double)messageLen / (double)fragmentCount);
|
||||
if(fragmentLen <= maxFragmentLen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return fragmentLen;
|
||||
}
|
||||
|
||||
public static class Part {
|
||||
private final long seqNum;
|
||||
private final int seqLen;
|
||||
private final int messageLen;
|
||||
private final long checksum;
|
||||
private final byte[] data;
|
||||
|
||||
public Part(long seqNum, int seqLen, int messageLen, long checksum, byte[] data) {
|
||||
this.seqNum = seqNum;
|
||||
this.seqLen = seqLen;
|
||||
this.messageLen = messageLen;
|
||||
this.checksum = checksum;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public long getSeqNum() {
|
||||
return seqNum;
|
||||
}
|
||||
|
||||
public int getSeqLen() {
|
||||
return seqLen;
|
||||
}
|
||||
|
||||
public int getMessageLen() {
|
||||
return messageLen;
|
||||
}
|
||||
|
||||
public long getChecksum() {
|
||||
return checksum;
|
||||
}
|
||||
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public byte[] toCborBytes() {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
new CborEncoder(baos).encode(new CborBuilder()
|
||||
.addArray()
|
||||
.add(new UnsignedInteger(seqNum))
|
||||
.add(new UnsignedInteger(seqLen))
|
||||
.add(new UnsignedInteger(messageLen))
|
||||
.add(new UnsignedInteger(checksum))
|
||||
.add(data)
|
||||
.end()
|
||||
.build());
|
||||
|
||||
return baos.toByteArray();
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static Part fromCborBytes(byte[] cborData) throws CborException {
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(cborData);
|
||||
List<DataItem> arrayDataItems = new CborDecoder(bais).decode();
|
||||
Array array = (Array)arrayDataItems.get(0);
|
||||
List<DataItem> dataItems = array.getDataItems();
|
||||
|
||||
UnsignedInteger seqNum = (UnsignedInteger)dataItems.get(0);
|
||||
UnsignedInteger seqLen = (UnsignedInteger)dataItems.get(1);
|
||||
UnsignedInteger messageLen = (UnsignedInteger)dataItems.get(2);
|
||||
UnsignedInteger checksum = (UnsignedInteger)dataItems.get(3);
|
||||
ByteString data = (ByteString)dataItems.get(4);
|
||||
|
||||
return new Part(seqNum.getValue().longValue(), seqLen.getValue().intValue(), messageLen.getValue().intValue(), checksum.getValue().longValue(), data.getBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur.fountain;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class FountainUtils {
|
||||
static List<Integer> chooseFragments(long seqNum, int seqLen, long checkSum) {
|
||||
// The first `seqLen` parts are the "pure" fragments, not mixed with any
|
||||
// others. This means that if you only generate the first `seqLen` parts,
|
||||
// then you have all the parts you need to decode the message.
|
||||
if(seqNum <= seqLen) {
|
||||
return List.of((int)seqNum - 1);
|
||||
} else {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES * 2);
|
||||
buffer.putInt((int)(seqNum));
|
||||
buffer.putInt((int)(checkSum));
|
||||
|
||||
RandomXoshiro256StarStar rng = new RandomXoshiro256StarStar(buffer.array());
|
||||
int degree = chooseDegree(seqLen, rng);
|
||||
List<Integer> indexes = IntStream.range(0, seqLen).boxed().collect(Collectors.toList());
|
||||
List<Integer> shuffledIndexes = shuffled(indexes, rng);
|
||||
return new ArrayList<>(shuffledIndexes.subList(0, degree));
|
||||
}
|
||||
}
|
||||
|
||||
static int chooseDegree(int seqLen, RandomXoshiro256StarStar rng) {
|
||||
List<Double> degreeProbabilties = IntStream.range(1, seqLen + 1).mapToObj(i -> 1 / (double)i).collect(Collectors.toList());
|
||||
RandomSampler randomSampler = new RandomSampler(degreeProbabilties);
|
||||
return randomSampler.next(rng) + 1;
|
||||
}
|
||||
|
||||
static List<Integer> shuffled(List<Integer> indexes, RandomXoshiro256StarStar rng) {
|
||||
List<Integer> remaining = new ArrayList<>(indexes);
|
||||
List<Integer> shuffled = new ArrayList<>(indexes.size());
|
||||
|
||||
while(!remaining.isEmpty()) {
|
||||
int index = rng.nextInt(0, remaining.size());
|
||||
Integer item = remaining.remove(index);
|
||||
shuffled.add(item);
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur.fountain;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Random-number sampling using the Walker-Vose alias method,
|
||||
* as described by Keith Schwarz (2011)
|
||||
* http://www.keithschwarz.com/darts-dice-coins
|
||||
*
|
||||
* Based on C implementation:
|
||||
* https://jugit.fz-juelich.de/mlz/ransampl
|
||||
*
|
||||
* Ported from https://github.com/BlockchainCommons/URKit
|
||||
*/
|
||||
public class RandomSampler {
|
||||
/* The probability and alias tables. */
|
||||
private final double[] probs;
|
||||
private final int[] aliases;
|
||||
|
||||
public RandomSampler(List<Double> probabilities) {
|
||||
if(probabilities.stream().anyMatch(prob -> prob < 0)) {
|
||||
throw new IllegalArgumentException("Probabilties must be > 0");
|
||||
}
|
||||
|
||||
// Normalize given probabilities
|
||||
double sum = probabilities.stream().reduce(0d, Double::sum);
|
||||
|
||||
int n = probabilities.size();
|
||||
List<Double> P = probabilities.stream().map(prob -> prob * (double)n / sum).collect(Collectors.toList());
|
||||
|
||||
List<Integer> S = new ArrayList<>();
|
||||
List<Integer> L = new ArrayList<>();
|
||||
|
||||
// Set separate index lists for small and large probabilities:
|
||||
for(int i = n - 1; i >= 0; i--) {
|
||||
// at variance from Schwarz, we reverse the index order
|
||||
if(P.get(i) < 1d) {
|
||||
S.add(i);
|
||||
} else {
|
||||
L.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Work through index lists
|
||||
double[] probs = new double[n];
|
||||
int[] aliases = new int[n];
|
||||
|
||||
while(!S.isEmpty() && !L.isEmpty()) {
|
||||
int a = S.remove(S.size() - 1);
|
||||
int g = L.remove(L.size() - 1);
|
||||
probs[a] = P.get(a);
|
||||
aliases[a] = g;
|
||||
P.set(g, P.get(g) + P.get(a) - 1);
|
||||
if(P.get(g) < 1) {
|
||||
S.add(g);
|
||||
} else {
|
||||
L.add(g);
|
||||
}
|
||||
}
|
||||
|
||||
while(!L.isEmpty()) {
|
||||
probs[L.remove(L.size() - 1)] = 1;
|
||||
}
|
||||
|
||||
while(!S.isEmpty()) {
|
||||
// can only happen through numeric instability
|
||||
probs[S.remove(S.size() - 1)] = 1;
|
||||
}
|
||||
|
||||
this.probs = probs;
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public int next(Random random) {
|
||||
double r1 = random.nextDouble();
|
||||
double r2 = random.nextDouble();
|
||||
int n = probs.length;
|
||||
int i = (int)((double)n * r1);
|
||||
return r2 < probs[i] ? i : aliases[i];
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package com.sparrowwallet.sparrow.ur.fountain;
|
||||
|
||||
/*
|
||||
* To the extent possible under law, the author has dedicated all copyright
|
||||
* and related and neighboring rights to this software to the public domain
|
||||
* worldwide. This software is distributed without any warranty.
|
||||
*
|
||||
* See <http://creativecommons.org/publicdomain/zero/1.0/>
|
||||
*/
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Implementation of Random based on the xoshiro256** RNG. No-dependencies
|
||||
* Java port of the <a href="http://xoshiro.di.unimi.it/xoshiro256starstar.c">original C code</a>,
|
||||
* which is public domain. This Java port is similarly dedicated to the public
|
||||
* domain.
|
||||
* <p>
|
||||
* Individual instances are not thread-safe. Each thread must have its own
|
||||
* instance which is not shared.
|
||||
*
|
||||
* @author David Blackman and Sebastiano Vigna <vigna@acm.org> (original C code)
|
||||
* @author Una Thompson <una@unascribed.com> (Java port)
|
||||
* @see <a href="http://xoshiro.di.unimi.it/">http://xoshiro.di.unimi.it/</a>
|
||||
*/
|
||||
public class RandomXoshiro256StarStar extends Random {
|
||||
private static final long serialVersionUID = -2837799889588687855L;
|
||||
|
||||
private static final AtomicLong uniq = new AtomicLong(System.nanoTime());
|
||||
|
||||
private static final long nextUniq() {
|
||||
return splitmix64_2(uniq.addAndGet(SPLITMIX1_MAGIC));
|
||||
}
|
||||
|
||||
private long seed;
|
||||
|
||||
public RandomXoshiro256StarStar() {
|
||||
this(System.nanoTime() ^ nextUniq());
|
||||
}
|
||||
|
||||
public RandomXoshiro256StarStar(long seed) {
|
||||
super(seed);
|
||||
// super will call setSeed
|
||||
}
|
||||
|
||||
public RandomXoshiro256StarStar(String seed) {
|
||||
this(seed.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public RandomXoshiro256StarStar(byte[] seed) {
|
||||
this(Sha256Hash.of(seed));
|
||||
}
|
||||
|
||||
public RandomXoshiro256StarStar(Sha256Hash digest) {
|
||||
long[] s = new long[4];
|
||||
byte[] digestBytes = digest.getBytes();
|
||||
|
||||
for(int i = 0; i < 4; i++) {
|
||||
int o = i * 8;
|
||||
long v = 0L;
|
||||
for(int n = 0; n < 8; n++) {
|
||||
v = v << 8;
|
||||
v |= digestBytes[o + n] & 0xFF;
|
||||
}
|
||||
s[i] = v;
|
||||
}
|
||||
|
||||
setState(s[0], s[1], s[2], s[3]);
|
||||
}
|
||||
|
||||
public RandomXoshiro256StarStar(long s1, long s2, long s3, long s4) {
|
||||
setState(s1, s2, s3, s4);
|
||||
}
|
||||
|
||||
// used to "stretch" seeds into a full 256-bit state; also makes
|
||||
// it safe to pass in zero as a seed
|
||||
////
|
||||
// what generator is used here is unimportant, as long as it's
|
||||
// from a different family, but splitmix64 happens to be an
|
||||
// incredibly simple high-quality generator of a completely
|
||||
// different family (and is recommended by the xoshiro authors)
|
||||
|
||||
private static final long SPLITMIX1_MAGIC = 0x9E3779B97F4A7C15L;
|
||||
|
||||
private static long splitmix64_1(long x) {
|
||||
return (x + SPLITMIX1_MAGIC);
|
||||
}
|
||||
|
||||
private static long splitmix64_2(long z) {
|
||||
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9L;
|
||||
z = (z ^ (z >> 27)) * 0x94D049BB133111EBL;
|
||||
return z ^ (z >> 31);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSeed(long seed) {
|
||||
this.seed = seed;
|
||||
|
||||
// update haveNextNextGaussian flag in super
|
||||
super.setSeed(seed);
|
||||
long sms = splitmix64_1(seed);
|
||||
s0 = splitmix64_2(sms);
|
||||
sms = splitmix64_1(sms);
|
||||
s1 = splitmix64_2(sms);
|
||||
sms = splitmix64_1(sms);
|
||||
s2 = splitmix64_2(sms);
|
||||
sms = splitmix64_1(sms);
|
||||
s3 = splitmix64_2(sms);
|
||||
}
|
||||
|
||||
public void setState(long s0, long s1, long s2, long s4) {
|
||||
if(s0 == 0 && s1 == 0 && s2 == 0 && s4 == 0) {
|
||||
throw new IllegalArgumentException("xoshiro256** state cannot be all zeroes");
|
||||
}
|
||||
this.s0 = s0;
|
||||
this.s1 = s1;
|
||||
this.s2 = s2;
|
||||
this.s3 = s4;
|
||||
}
|
||||
|
||||
// not called, implemented instead of just throwing for completeness
|
||||
@Override
|
||||
protected int next(int bits) {
|
||||
return (int) (nextLong() & ((1L << bits) - 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int nextInt() {
|
||||
return (int) nextLong();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int nextInt(int bound) {
|
||||
return (int) nextLong(bound);
|
||||
}
|
||||
|
||||
public long nextLong(long bound) {
|
||||
if(bound <= 0) {
|
||||
throw new IllegalArgumentException("bound must be positive");
|
||||
}
|
||||
// clear sign bit for positive-only, modulo to bound
|
||||
return (nextLong() & Long.MAX_VALUE) % bound;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double nextDouble() {
|
||||
return (nextLong() >>> 11) * 0x1.0P-53;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float nextFloat() {
|
||||
return (nextLong() >>> 40) * 0x1.0P-24f;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean nextBoolean() {
|
||||
return (nextLong() & 1) != 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void nextBytes(byte[] buf) {
|
||||
nextBytes(buf, 0, buf.length);
|
||||
}
|
||||
|
||||
public void nextBytes(byte[] buf, int ofs, int len) {
|
||||
if(ofs < 0) {
|
||||
throw new ArrayIndexOutOfBoundsException("Offset " + ofs + " is negative");
|
||||
}
|
||||
if(ofs >= buf.length) {
|
||||
throw new ArrayIndexOutOfBoundsException("Offset " + ofs + " is greater than buffer length");
|
||||
}
|
||||
if(ofs + len > buf.length) {
|
||||
throw new ArrayIndexOutOfBoundsException("Length " + len + " with offset " + ofs + " is past end of buffer");
|
||||
}
|
||||
int j = 8;
|
||||
long l = 0;
|
||||
for(int i = ofs; i < ofs + len; i++) {
|
||||
if(j >= 8) {
|
||||
l = nextLong();
|
||||
j = 0;
|
||||
}
|
||||
buf[i] = (byte) (l & 0xFF);
|
||||
l = l >>> 8L;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
public void nextData(byte[] data) {
|
||||
for(int i = 0; i < data.length; i++) {
|
||||
data[i] = (byte)(nextInt(0, 256) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
public int nextInt(int lowerBound, int count) {
|
||||
double next = nextDouble();
|
||||
double dou = (next * count);
|
||||
return (int)(dou) + lowerBound;
|
||||
}
|
||||
|
||||
/* This is xoshiro256** 1.0, our all-purpose, rock-solid generator. It has
|
||||
excellent (sub-ns) speed, a state (256 bits) that is large enough for
|
||||
any parallel application, and it passes all tests we are aware of.
|
||||
|
||||
For generating just floating-point numbers, xoshiro256+ is even faster.
|
||||
|
||||
The state must be seeded so that it is not everywhere zero. If you have
|
||||
a 64-bit seed, we suggest to seed a splitmix64 generator and use its
|
||||
output to fill s. */
|
||||
|
||||
private static long rotl(long x, int k) {
|
||||
return (x << k) | (x >>> (64 - k));
|
||||
}
|
||||
|
||||
|
||||
private long s0;
|
||||
private long s1;
|
||||
private long s2;
|
||||
private long s3;
|
||||
|
||||
@Override
|
||||
public long nextLong() {
|
||||
long result_starstar = rotl(s1 * 5, 7) * 9;
|
||||
|
||||
long t = s1 << 17;
|
||||
|
||||
s2 ^= s0;
|
||||
s3 ^= s1;
|
||||
s1 ^= s2;
|
||||
s0 ^= s3;
|
||||
|
||||
s2 ^= t;
|
||||
|
||||
s3 = rotl(s3, 45);
|
||||
|
||||
return result_starstar;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.wallet.WalletNode;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.AddressTreeTable;
|
||||
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletEntryLabelChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.WalletNodesChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.control.Spinner;
|
||||
import javafx.scene.control.SpinnerValueFactory;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
public class AdvancedController implements Initializable {
|
||||
@FXML
|
||||
private Spinner<Integer> gapLimit;
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
|
||||
}
|
||||
|
||||
public void initializeView(Wallet wallet) {
|
||||
gapLimit.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(Wallet.DEFAULT_LOOKAHEAD, 10000, wallet.getGapLimit()));
|
||||
gapLimit.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
wallet.setGapLimit(newValue);
|
||||
EventManager.get().post(new SettingsChangedEvent(wallet, SettingsChangedEvent.Type.GAP_LIMIT));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.control.ButtonBar;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.DialogPane;
|
||||
import org.controlsfx.tools.Borders;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class AdvancedDialog extends Dialog<Void> {
|
||||
public AdvancedDialog(Wallet wallet) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppController.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
try {
|
||||
FXMLLoader advancedLoader = new FXMLLoader(AppController.class.getResource("wallet/advanced.fxml"));
|
||||
dialogPane.setContent(Borders.wrap(advancedLoader.load()).emptyBorder().buildAll());
|
||||
AdvancedController settingsAdvancedController = advancedLoader.getController();
|
||||
settingsAdvancedController.initializeView(wallet);
|
||||
|
||||
final ButtonType closeButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(closeButtonType);
|
||||
|
||||
dialogPane.setPrefWidth(400);
|
||||
dialogPane.setPrefHeight(300);
|
||||
}
|
||||
catch(IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,6 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
|
||||
return o.getHashIndex().getComparisonHeight() - getHashIndex().getComparisonHeight();
|
||||
}
|
||||
|
||||
return (int)o.getHashIndex().getIndex() - (int)getHashIndex().getIndex();
|
||||
return Long.compare(o.getHashIndex().getIndex(), (int)getHashIndex().getIndex());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.*;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.KeystoreSource;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.QRDisplayDialog;
|
||||
import com.sparrowwallet.sparrow.control.QRScanDialog;
|
||||
import com.sparrowwallet.sparrow.control.SeedDisplayDialog;
|
||||
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
@@ -24,13 +23,17 @@ import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import tornadofx.control.Field;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.Optional;
|
||||
@@ -38,6 +41,8 @@ import java.util.ResourceBundle;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class KeystoreController extends WalletFormController implements Initializable {
|
||||
private static final Logger log = LoggerFactory.getLogger(KeystoreController.class);
|
||||
|
||||
private Keystore keystore;
|
||||
|
||||
@FXML
|
||||
@@ -58,6 +63,9 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
@FXML
|
||||
private TextField label;
|
||||
|
||||
@FXML
|
||||
private Field xpubField;
|
||||
|
||||
@FXML
|
||||
private TextArea xpub;
|
||||
|
||||
@@ -67,6 +75,15 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
@FXML
|
||||
private TextField fingerprint;
|
||||
|
||||
@FXML
|
||||
private Button scanXpubQR;
|
||||
|
||||
@FXML
|
||||
private Button displayXpubQR;
|
||||
|
||||
@FXML
|
||||
private Button switchXpubHeader;
|
||||
|
||||
private final ValidationSupport validationSupport = new ValidationSupport();
|
||||
|
||||
@Override
|
||||
@@ -84,11 +101,14 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
Platform.runLater(this::setupValidation);
|
||||
|
||||
selectSourcePane.managedProperty().bind(selectSourcePane.visibleProperty());
|
||||
if(keystore.isValid()) {
|
||||
if(keystore.isValid() || keystore.getExtendedPublicKey() != null) {
|
||||
selectSourcePane.setVisible(false);
|
||||
}
|
||||
|
||||
viewSeedButton.managedProperty().bind(viewSeedButton.visibleProperty());
|
||||
scanXpubQR.managedProperty().bind(scanXpubQR.visibleProperty());
|
||||
displayXpubQR.managedProperty().bind(displayXpubQR.visibleProperty());
|
||||
displayXpubQR.visibleProperty().bind(scanXpubQR.visibleProperty().not());
|
||||
|
||||
updateType();
|
||||
|
||||
@@ -98,7 +118,10 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
|
||||
if(keystore.getExtendedPublicKey() != null) {
|
||||
xpub.setText(keystore.getExtendedPublicKey().toString());
|
||||
setXpubTooltip(keystore.getExtendedPublicKey());
|
||||
setXpubContext(keystore.getExtendedPublicKey());
|
||||
} else {
|
||||
switchXpubHeader.setDisable(true);
|
||||
xpubField.setText(Network.get().getXpubHeader().getDisplayName() + ":");
|
||||
}
|
||||
|
||||
if(keystore.getKeyDerivation() != null) {
|
||||
@@ -123,25 +146,56 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
}
|
||||
});
|
||||
xpub.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
if(ExtendedKey.isValid(newValue)) {
|
||||
boolean valid = ExtendedKey.isValid(newValue);
|
||||
if(valid) {
|
||||
ExtendedKey extendedKey = ExtendedKey.fromDescriptor(newValue);
|
||||
setXpubTooltip(extendedKey);
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_XPUB));
|
||||
setXpubContext(extendedKey);
|
||||
if(!extendedKey.equals(keystore.getExtendedPublicKey()) && extendedKey.getKey().isPubKeyOnly()) {
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
EventManager.get().post(new SettingsChangedEvent(walletForm.getWallet(), SettingsChangedEvent.Type.KEYSTORE_XPUB));
|
||||
}
|
||||
} else {
|
||||
xpub.setTooltip(null);
|
||||
xpub.setContextMenu(null);
|
||||
switchXpubHeader.setDisable(true);
|
||||
}
|
||||
scanXpubQR.setVisible(!valid);
|
||||
});
|
||||
}
|
||||
|
||||
private void setXpubTooltip(ExtendedKey extendedKey) {
|
||||
private void setXpubContext(ExtendedKey extendedKey) {
|
||||
ContextMenu contextMenu = new ContextMenu();
|
||||
MenuItem copyXPub = new MenuItem("Copy " + Network.get().getXpubHeader().getDisplayName());
|
||||
copyXPub.setOnAction(AE -> {
|
||||
contextMenu.hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(extendedKey.toString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
contextMenu.getItems().add(copyXPub);
|
||||
|
||||
ExtendedKey.Header header = ExtendedKey.Header.fromScriptType(walletForm.getWallet().getScriptType(), false);
|
||||
if(header != ExtendedKey.Header.xpub) {
|
||||
Tooltip tooltip = new Tooltip(extendedKey.getExtendedKey(header));
|
||||
xpub.setTooltip(tooltip);
|
||||
if(header != Network.get().getXpubHeader()) {
|
||||
String otherPub = extendedKey.getExtendedKey(header);
|
||||
|
||||
MenuItem copyOtherPub = new MenuItem("Copy " + header.getDisplayName());
|
||||
copyOtherPub.setOnAction(AE -> {
|
||||
contextMenu.hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(otherPub);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
contextMenu.getItems().add(copyOtherPub);
|
||||
|
||||
xpubField.setText(Network.get().getXpubHeader().getDisplayName() + " / " + header.getDisplayName() + ":");
|
||||
switchXpubHeader.setDisable(false);
|
||||
switchXpubHeader.setTooltip(new Tooltip("Show as " + header.getDisplayName()));
|
||||
} else {
|
||||
xpub.setTooltip(null);
|
||||
xpubField.setText(Network.get().getXpubHeader().getDisplayName() + ":");
|
||||
switchXpubHeader.setDisable(true);
|
||||
}
|
||||
|
||||
xpub.setContextMenu(contextMenu);
|
||||
scanXpubQR.setVisible(false);
|
||||
}
|
||||
|
||||
public void selectSource(ActionEvent event) {
|
||||
@@ -171,8 +225,8 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
));
|
||||
|
||||
validationSupport.registerValidator(xpub, Validator.combine(
|
||||
Validator.createEmptyValidator("xPub is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "xPub is invalid", !ExtendedKey.isValid(newValue))
|
||||
Validator.createEmptyValidator(Network.get().getXpubHeader().getDisplayName() + " is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, Network.get().getXpubHeader().getDisplayName() + " is invalid", !ExtendedKey.isValid(newValue))
|
||||
));
|
||||
|
||||
validationSupport.registerValidator(derivation, Validator.combine(
|
||||
@@ -183,7 +237,7 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
|
||||
validationSupport.registerValidator(fingerprint, Validator.combine(
|
||||
Validator.createEmptyValidator("Master fingerprint is required"),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Master fingerprint is invalid", (newValue.length() != 8 || !Utils.isHex(newValue)))
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Master fingerprint is invalid", (newValue == null || newValue.length() != 8 || !Utils.isHex(newValue)))
|
||||
));
|
||||
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
@@ -201,6 +255,7 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
fingerprint.setEditable(editable);
|
||||
derivation.setEditable(editable);
|
||||
xpub.setEditable(editable);
|
||||
scanXpubQR.setVisible(editable);
|
||||
}
|
||||
|
||||
private String getTypeLabel(Keystore keystore) {
|
||||
@@ -243,7 +298,7 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
private void launchImportDialog(KeystoreSource initialSource) {
|
||||
KeystoreImportDialog dlg = new KeystoreImportDialog(getWalletForm().getWallet(), initialSource);
|
||||
Optional<Keystore> result = dlg.showAndWait();
|
||||
if (result.isPresent()) {
|
||||
if(result.isPresent()) {
|
||||
selectSourcePane.setVisible(false);
|
||||
|
||||
Keystore importedKeystore = result.get();
|
||||
@@ -259,7 +314,13 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
label.setText(keystore.getLabel());
|
||||
fingerprint.setText(keystore.getKeyDerivation().getMasterFingerprint());
|
||||
derivation.setText(keystore.getKeyDerivation().getDerivationPath());
|
||||
xpub.setText(keystore.getExtendedPublicKey().toString());
|
||||
|
||||
if(keystore.getExtendedPublicKey() != null) {
|
||||
xpub.setText(keystore.getExtendedPublicKey().toString());
|
||||
setXpubContext(keystore.getExtendedPublicKey());
|
||||
} else {
|
||||
xpub.setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,17 +355,53 @@ public class KeystoreController extends WalletFormController implements Initiali
|
||||
dlg.showAndWait();
|
||||
}
|
||||
|
||||
public void scanXpubQR(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.extendedKey != null && result.extendedKey.getKey().isPubKeyOnly()) {
|
||||
xpub.setText(result.extendedKey.getExtendedKey());
|
||||
} else if(result.exception != null) {
|
||||
log.error("Error scanning QR", result.exception);
|
||||
AppController.showErrorDialog("Error scanning QR", result.exception.getMessage());
|
||||
} else {
|
||||
AppController.showErrorDialog("Invalid QR Code", "QR Code did not contain a valid " + Network.get().getXpubHeader().getDisplayName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void displayXpubQR(ActionEvent event) {
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(xpub.getText());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
|
||||
public void switchXpubHeader(ActionEvent event) {
|
||||
if(keystore.getExtendedPublicKey() != null) {
|
||||
ExtendedKey.Header header = ExtendedKey.Header.fromScriptType(walletForm.getWallet().getScriptType(), false);
|
||||
if(!xpub.getText().startsWith(header.getName())) {
|
||||
String otherPub = keystore.getExtendedPublicKey().getExtendedKey(header);
|
||||
xpub.setText(otherPub);
|
||||
switchXpubHeader.setTooltip(new Tooltip("Show as " + Network.get().getXpubHeader().getDisplayName()));
|
||||
} else {
|
||||
String xPub = keystore.getExtendedPublicKey().getExtendedKey();
|
||||
xpub.setText(xPub);
|
||||
switchXpubHeader.setTooltip(new Tooltip("Show as " + header.getDisplayName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void update(SettingsChangedEvent event) {
|
||||
if(walletForm.getWallet().equals(event.getWallet()) && event.getType().equals(SettingsChangedEvent.Type.SCRIPT_TYPE)) {
|
||||
derivation.setPromptText(event.getWallet().getScriptType().getDefaultDerivationPath());
|
||||
if(!derivation.getText().isEmpty()) {
|
||||
if(derivation.getText() != null && !derivation.getText().isEmpty()) {
|
||||
String derivationPath = derivation.getText();
|
||||
derivation.setText(derivationPath + " ");
|
||||
derivation.setText(derivationPath);
|
||||
}
|
||||
if(keystore.getExtendedPublicKey() != null) {
|
||||
setXpubTooltip(keystore.getExtendedPublicKey());
|
||||
setXpubContext(keystore.getExtendedPublicKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.address.P2PKHAddress;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
import com.sparrowwallet.drongo.uri.BitcoinURI;
|
||||
import com.sparrowwallet.drongo.wallet.MaxUtxoSelector;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.UtxoSelector;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.CoinTextFormatter;
|
||||
import com.sparrowwallet.sparrow.control.CopyableTextField;
|
||||
import com.sparrowwallet.sparrow.control.FiatLabel;
|
||||
import com.sparrowwallet.sparrow.control.QRScanDialog;
|
||||
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
|
||||
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.ExchangeSource;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.control.*;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
|
||||
import java.net.URL;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.*;
|
||||
|
||||
public class PaymentController extends WalletFormController implements Initializable {
|
||||
private SendController sendController;
|
||||
|
||||
private ValidationSupport validationSupport;
|
||||
|
||||
@FXML
|
||||
private CopyableTextField address;
|
||||
|
||||
@FXML
|
||||
private TextField label;
|
||||
|
||||
@FXML
|
||||
private TextField amount;
|
||||
|
||||
@FXML
|
||||
private ComboBox<BitcoinUnit> amountUnit;
|
||||
|
||||
@FXML
|
||||
private FiatLabel fiatAmount;
|
||||
|
||||
@FXML
|
||||
private Button maxButton;
|
||||
|
||||
@FXML
|
||||
private Button addPaymentButton;
|
||||
|
||||
private final ChangeListener<String> amountListener = new ChangeListener<>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
||||
if(sendController.getUtxoSelector() instanceof MaxUtxoSelector) {
|
||||
sendController.utxoSelectorProperty().setValue(null);
|
||||
}
|
||||
|
||||
Long recipientValueSats = getRecipientValueSats();
|
||||
if(recipientValueSats != null) {
|
||||
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), recipientValueSats);
|
||||
} else {
|
||||
fiatAmount.setText("");
|
||||
}
|
||||
|
||||
sendController.updateTransaction();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
}
|
||||
|
||||
public void setSendController(SendController sendController) {
|
||||
this.sendController = sendController;
|
||||
this.validationSupport = sendController.getValidationSupport();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeView() {
|
||||
address.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
try {
|
||||
BitcoinURI bitcoinURI = new BitcoinURI(newValue);
|
||||
Platform.runLater(() -> updateFromURI(bitcoinURI));
|
||||
return;
|
||||
} catch(Exception e) {
|
||||
//ignore, not a URI
|
||||
}
|
||||
|
||||
revalidate(amount, amountListener);
|
||||
maxButton.setDisable(!isValidRecipientAddress());
|
||||
sendController.updateTransaction();
|
||||
|
||||
if(validationSupport != null) {
|
||||
validationSupport.setErrorDecorationEnabled(true);
|
||||
}
|
||||
});
|
||||
|
||||
label.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
sendController.getCreateButton().setDisable(sendController.getWalletTransaction() == null || newValue == null || newValue.isEmpty() || sendController.isInsufficientFeeRate());
|
||||
sendController.updateTransaction();
|
||||
});
|
||||
|
||||
amount.setTextFormatter(new CoinTextFormatter());
|
||||
amount.textProperty().addListener(amountListener);
|
||||
|
||||
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(sendController.getBitcoinUnit(Config.get().getBitcoinUnit())) ? 0 : 1);
|
||||
amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
Long value = getRecipientValueSats(oldValue);
|
||||
if(value != null) {
|
||||
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
df.setMaximumFractionDigits(8);
|
||||
amount.setText(df.format(newValue.getValue(value)));
|
||||
}
|
||||
});
|
||||
|
||||
maxButton.setDisable(!isValidRecipientAddress());
|
||||
sendController.utxoLabelSelectionProperty().addListener((observable, oldValue, newValue) -> {
|
||||
maxButton.setText("Max" + newValue);
|
||||
});
|
||||
|
||||
Optional<Tab> firstTab = sendController.getPaymentTabs().getTabs().stream().findFirst();
|
||||
if(firstTab.isPresent()) {
|
||||
PaymentController controller = (PaymentController)firstTab.get().getUserData();
|
||||
String firstLabel = controller.label.getText();
|
||||
label.setText(firstLabel);
|
||||
}
|
||||
|
||||
addValidation(validationSupport);
|
||||
}
|
||||
|
||||
private void addValidation(ValidationSupport validationSupport) {
|
||||
this.validationSupport = validationSupport;
|
||||
|
||||
validationSupport.registerValidator(address, Validator.combine(
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress())
|
||||
));
|
||||
validationSupport.registerValidator(label, Validator.combine(
|
||||
Validator.createEmptyValidator("Label is required")
|
||||
));
|
||||
validationSupport.registerValidator(amount, Validator.combine(
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", sendController.isInsufficientInputs()),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold())
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isValidRecipientAddress() {
|
||||
try {
|
||||
getRecipientAddress();
|
||||
return true;
|
||||
} catch (InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Address getRecipientAddress() throws InvalidAddressException {
|
||||
return Address.fromString(address.getText());
|
||||
}
|
||||
|
||||
private Long getRecipientValueSats() {
|
||||
return getRecipientValueSats(amountUnit.getSelectionModel().getSelectedItem());
|
||||
}
|
||||
|
||||
private Long getRecipientValueSats(BitcoinUnit bitcoinUnit) {
|
||||
if(amount.getText() != null && !amount.getText().isEmpty()) {
|
||||
double fieldValue = Double.parseDouble(amount.getText().replaceAll(",", ""));
|
||||
return bitcoinUnit.getSatsValue(fieldValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void setRecipientValueSats(long recipientValue) {
|
||||
amount.textProperty().removeListener(amountListener);
|
||||
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
df.setMaximumFractionDigits(8);
|
||||
amount.setText(df.format(amountUnit.getValue().getValue(recipientValue)));
|
||||
amount.textProperty().addListener(amountListener);
|
||||
}
|
||||
|
||||
private long getRecipientDustThreshold() {
|
||||
Address address;
|
||||
try {
|
||||
address = getRecipientAddress();
|
||||
} catch(InvalidAddressException e) {
|
||||
address = new P2PKHAddress(new byte[20]);
|
||||
}
|
||||
|
||||
TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript());
|
||||
return address.getScriptType().getDustThreshold(txOutput, sendController.getFeeRate());
|
||||
}
|
||||
|
||||
private void setFiatAmount(CurrencyRate currencyRate, Long amount) {
|
||||
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
|
||||
fiatAmount.set(currencyRate, amount);
|
||||
}
|
||||
}
|
||||
|
||||
public void revalidate() {
|
||||
revalidate(amount, amountListener);
|
||||
}
|
||||
|
||||
private void revalidate(TextField field, ChangeListener<String> listener) {
|
||||
field.textProperty().removeListener(listener);
|
||||
String amt = field.getText();
|
||||
field.setText(amt + "0");
|
||||
field.setText(amt);
|
||||
field.textProperty().addListener(listener);
|
||||
}
|
||||
|
||||
public boolean isValidPayment() {
|
||||
try {
|
||||
getPayment();
|
||||
return true;
|
||||
} catch(IllegalStateException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Payment getPayment() {
|
||||
return getPayment(false);
|
||||
}
|
||||
|
||||
public Payment getPayment(boolean sendAll) {
|
||||
try {
|
||||
Address address = getRecipientAddress();
|
||||
Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats();
|
||||
|
||||
if(!label.getText().isEmpty() && value != null && value > getRecipientDustThreshold()) {
|
||||
return new Payment(address, label.getText(), value, sendAll);
|
||||
}
|
||||
} catch(InvalidAddressException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
throw new IllegalStateException("Invalid payment specified");
|
||||
}
|
||||
|
||||
public void setPayment(Payment payment) {
|
||||
if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) {
|
||||
setRecipientValueSats(payment.getAmount());
|
||||
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), payment.getAmount());
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
address.setText("");
|
||||
label.setText("");
|
||||
|
||||
amount.textProperty().removeListener(amountListener);
|
||||
amount.setText("");
|
||||
amount.textProperty().addListener(amountListener);
|
||||
|
||||
fiatAmount.setText("");
|
||||
}
|
||||
|
||||
public void setMaxInput(ActionEvent event) {
|
||||
UtxoSelector utxoSelector = sendController.getUtxoSelector();
|
||||
if(utxoSelector == null) {
|
||||
MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector();
|
||||
sendController.utxoSelectorProperty().set(maxUtxoSelector);
|
||||
}
|
||||
|
||||
try {
|
||||
List<Payment> payments = new ArrayList<>();
|
||||
for(Tab tab : sendController.getPaymentTabs().getTabs()) {
|
||||
PaymentController controller = (PaymentController)tab.getUserData();
|
||||
if(controller != this) {
|
||||
payments.add(controller.getPayment());
|
||||
} else {
|
||||
payments.add(getPayment(true));
|
||||
}
|
||||
}
|
||||
sendController.updateTransaction(payments);
|
||||
} catch(IllegalStateException e) {
|
||||
//ignore, validation errors
|
||||
}
|
||||
}
|
||||
|
||||
public void scanQrAddress(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.uri != null) {
|
||||
updateFromURI(result.uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateFromURI(BitcoinURI bitcoinURI) {
|
||||
if(bitcoinURI.getAddress() != null) {
|
||||
address.setText(bitcoinURI.getAddress().toString());
|
||||
}
|
||||
if(bitcoinURI.getLabel() != null) {
|
||||
label.setText(bitcoinURI.getLabel());
|
||||
}
|
||||
if(bitcoinURI.getAmount() != null) {
|
||||
setRecipientValueSats(bitcoinURI.getAmount());
|
||||
}
|
||||
if(bitcoinURI.getPayjoinUrl() != null) {
|
||||
AppController.addPayjoinURI(bitcoinURI);
|
||||
}
|
||||
sendController.updateTransaction();
|
||||
}
|
||||
|
||||
public void addPayment(ActionEvent event) {
|
||||
sendController.addPaymentTab();
|
||||
}
|
||||
|
||||
public Button getAddPaymentButton() {
|
||||
return addPaymentButton;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
|
||||
BitcoinUnit unit = sendController.getBitcoinUnit(event.getBitcoinUnit());
|
||||
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
|
||||
if(event.getExchangeSource() == ExchangeSource.NONE) {
|
||||
fiatAmount.setCurrency(null);
|
||||
fiatAmount.setBtcRate(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
|
||||
setFiatAmount(event.getCurrencyRate(), getRecipientValueSats());
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ public class ReceiveController extends WalletFormController implements Initializ
|
||||
scriptPubKeyArea.appendScript(nodeEntry.getOutputScript(), null, null);
|
||||
|
||||
outputDescriptor.clear();
|
||||
outputDescriptor.appendText(nodeEntry.getOutputDescriptor());
|
||||
outputDescriptor.append(nodeEntry.getOutputDescriptor(), "descriptor-text");
|
||||
|
||||
updateDisplayAddress(AppController.getDevices());
|
||||
}
|
||||
|
||||
@@ -16,51 +16,44 @@ import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.io.ExchangeSource;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.util.StringConverter;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.Validator;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class SendController extends WalletFormController implements Initializable {
|
||||
private static final Logger log = LoggerFactory.getLogger(SendController.class);
|
||||
|
||||
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50, 100, 500);
|
||||
|
||||
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
|
||||
|
||||
@FXML
|
||||
private CopyableTextField address;
|
||||
|
||||
@FXML
|
||||
private TextField label;
|
||||
|
||||
@FXML
|
||||
private TextField amount;
|
||||
|
||||
@FXML
|
||||
private ComboBox<BitcoinUnit> amountUnit;
|
||||
|
||||
@FXML
|
||||
private FiatLabel fiatAmount;
|
||||
|
||||
@FXML
|
||||
private Button maxButton;
|
||||
private TabPane paymentTabs;
|
||||
|
||||
@FXML
|
||||
private Slider targetBlocks;
|
||||
@@ -89,31 +82,21 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
@FXML
|
||||
private Button createButton;
|
||||
|
||||
private StackPane tabHeader;
|
||||
|
||||
private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false);
|
||||
|
||||
private final ObjectProperty<UtxoSelector> utxoSelectorProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
private final ObjectProperty<UtxoFilter> utxoFilterProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
private final ObjectProperty<WalletTransaction> walletTransactionProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
private final ObjectProperty<WalletTransaction> createdWalletTransactionProperty = new SimpleObjectProperty<>(null);
|
||||
|
||||
private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false);
|
||||
|
||||
private final ChangeListener<String> amountListener = new ChangeListener<>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
|
||||
if(utxoSelectorProperty.get() instanceof MaxUtxoSelector) {
|
||||
utxoSelectorProperty.setValue(null);
|
||||
}
|
||||
|
||||
Long recipientValueSats = getRecipientValueSats();
|
||||
if(recipientValueSats != null) {
|
||||
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), recipientValueSats);
|
||||
} else {
|
||||
fiatAmount.setText("");
|
||||
}
|
||||
|
||||
updateTransaction();
|
||||
}
|
||||
};
|
||||
private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty("");
|
||||
|
||||
private final ChangeListener<String> feeListener = new ChangeListener<>() {
|
||||
@Override
|
||||
@@ -147,11 +130,16 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
targetBlocks.setTooltip(tooltip);
|
||||
|
||||
userFeeSet.set(false);
|
||||
revalidate(amount, amountListener);
|
||||
for(Tab tab : paymentTabs.getTabs()) {
|
||||
PaymentController controller = (PaymentController)tab.getUserData();
|
||||
controller.revalidate();
|
||||
}
|
||||
updateTransaction();
|
||||
}
|
||||
};
|
||||
|
||||
private ValidationSupport validationSupport;
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
EventManager.get().register(this);
|
||||
@@ -159,38 +147,36 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
|
||||
@Override
|
||||
public void initializeView() {
|
||||
address.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
revalidate(amount, amountListener);
|
||||
maxButton.setDisable(!isValidRecipientAddress());
|
||||
addValidation();
|
||||
|
||||
addPaymentTab();
|
||||
initializeTabHeader(0);
|
||||
|
||||
paymentTabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
|
||||
if(tabHeader != null) {
|
||||
tabHeader.setVisible(c.getList().size() > 1);
|
||||
}
|
||||
|
||||
if(c.getList().size() > 1) {
|
||||
if(!paymentTabs.getStyleClass().contains("multiple-tabs")) {
|
||||
paymentTabs.getStyleClass().add("multiple-tabs");
|
||||
}
|
||||
paymentTabs.getTabs().forEach(tab -> tab.setClosable(true));
|
||||
} else {
|
||||
paymentTabs.getStyleClass().remove("multiple-tabs");
|
||||
Tab remainingTab = paymentTabs.getTabs().get(0);
|
||||
remainingTab.setClosable(false);
|
||||
remainingTab.setText("1");
|
||||
}
|
||||
|
||||
updateTransaction();
|
||||
});
|
||||
|
||||
label.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
createButton.setDisable(walletTransactionProperty.get() == null || newValue == null || newValue.isEmpty());
|
||||
});
|
||||
|
||||
amount.setTextFormatter(new CoinTextFormatter());
|
||||
amount.textProperty().addListener(amountListener);
|
||||
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = getWalletForm().getWallet().getAutoUnit();
|
||||
}
|
||||
|
||||
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
||||
amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
Long value = getRecipientValueSats(oldValue);
|
||||
if(value != null) {
|
||||
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
df.setMaximumFractionDigits(8);
|
||||
amount.setText(df.format(newValue.getValue(value)));
|
||||
}
|
||||
});
|
||||
|
||||
maxButton.setDisable(!isValidRecipientAddress());
|
||||
|
||||
insufficientInputsProperty.addListener((observable, oldValue, newValue) -> {
|
||||
revalidate(amount, amountListener);
|
||||
for(Tab tab : paymentTabs.getTabs()) {
|
||||
PaymentController controller = (PaymentController)tab.getUserData();
|
||||
controller.revalidate();
|
||||
}
|
||||
revalidate(fee, feeListener);
|
||||
});
|
||||
|
||||
@@ -228,6 +214,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
fee.setTextFormatter(new CoinTextFormatter());
|
||||
fee.textProperty().addListener(feeListener);
|
||||
|
||||
BitcoinUnit unit = getBitcoinUnit(Config.get().getBitcoinUnit());
|
||||
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
||||
feeAmountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
Long value = getFeeValueSats(oldValue);
|
||||
@@ -249,27 +236,27 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
}
|
||||
});
|
||||
|
||||
utxoLabelSelectionProperty.addListener((observable, oldValue, newValue) -> {
|
||||
clearButton.setText("Clear" + newValue);
|
||||
});
|
||||
|
||||
utxoSelectorProperty.addListener((observable, oldValue, utxoSelector) -> {
|
||||
if(utxoSelector instanceof PresetUtxoSelector) {
|
||||
PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
|
||||
int num = presetUtxoSelector.getPresetUtxos().size();
|
||||
String selection = " (" + num + " UTXO" + (num > 1 ? "s" : "") + " selected)";
|
||||
maxButton.setText("Max" + selection);
|
||||
clearButton.setText("Clear" + selection);
|
||||
} else {
|
||||
maxButton.setText("Max");
|
||||
clearButton.setText("Clear");
|
||||
}
|
||||
updateMaxClearButtons(utxoSelector, utxoFilterProperty.get());
|
||||
});
|
||||
|
||||
utxoFilterProperty.addListener((observable, oldValue, utxoFilter) -> {
|
||||
updateMaxClearButtons(utxoSelectorProperty.get(), utxoFilter);
|
||||
});
|
||||
|
||||
walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> {
|
||||
if(walletTransaction != null) {
|
||||
if(getRecipientValueSats() == null || walletTransaction.getRecipientAmount() != getRecipientValueSats()) {
|
||||
setRecipientValueSats(walletTransaction.getRecipientAmount());
|
||||
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), walletTransaction.getRecipientAmount());
|
||||
for(int i = 0; i < paymentTabs.getTabs().size(); i++) {
|
||||
Payment payment = walletTransaction.getPayments().get(i);
|
||||
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(i).getUserData();
|
||||
controller.setPayment(payment);
|
||||
}
|
||||
|
||||
double feeRate = (double)walletTransaction.getFee() / walletTransaction.getTransaction().getVirtualSize();
|
||||
double feeRate = walletTransaction.getFeeRate();
|
||||
if(userFeeSet.get()) {
|
||||
setTargetBlocks(getTargetBlocks(feeRate));
|
||||
} else {
|
||||
@@ -280,56 +267,117 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
}
|
||||
|
||||
transactionDiagram.update(walletTransaction);
|
||||
createButton.setDisable(walletTransaction == null || label.getText().isEmpty());
|
||||
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
|
||||
});
|
||||
}
|
||||
|
||||
addValidation();
|
||||
private void initializeTabHeader(int count) {
|
||||
final int lookupCount = count;
|
||||
Platform.runLater(() -> {
|
||||
StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
|
||||
if(stackPane != null) {
|
||||
tabHeader = stackPane;
|
||||
tabHeader.managedProperty().bind(tabHeader.visibleProperty());
|
||||
tabHeader.setVisible(false);
|
||||
paymentTabs.getStyleClass().remove("initial");
|
||||
} else if(lookupCount < 20) {
|
||||
initializeTabHeader(lookupCount+1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public BitcoinUnit getBitcoinUnit(BitcoinUnit bitcoinUnit) {
|
||||
BitcoinUnit unit = bitcoinUnit;
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = getWalletForm().getWallet().getAutoUnit();
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
public ValidationSupport getValidationSupport() {
|
||||
return validationSupport;
|
||||
}
|
||||
|
||||
private void addValidation() {
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(address, Validator.combine(
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress())
|
||||
));
|
||||
validationSupport.registerValidator(label, Validator.combine(
|
||||
Validator.createEmptyValidator("Label is required")
|
||||
));
|
||||
validationSupport.registerValidator(amount, Validator.combine(
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", insufficientInputsProperty.get()),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold())
|
||||
));
|
||||
validationSupport = new ValidationSupport();
|
||||
validationSupport.registerValidator(fee, Validator.combine(
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", userFeeSet.get() && insufficientInputsProperty.get()),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0)
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0),
|
||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee Rate", isInsufficientFeeRate())
|
||||
));
|
||||
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.setErrorDecorationEnabled(false);
|
||||
}
|
||||
|
||||
private void updateTransaction() {
|
||||
updateTransaction(false);
|
||||
public Tab addPaymentTab() {
|
||||
Tab tab = getPaymentTab();
|
||||
paymentTabs.getTabs().add(tab);
|
||||
paymentTabs.getSelectionModel().select(tab);
|
||||
return tab;
|
||||
}
|
||||
|
||||
private void updateTransaction(boolean sendAll) {
|
||||
public Tab getPaymentTab() {
|
||||
Tab tab = new Tab(Integer.toString(paymentTabs.getTabs().size() + 1));
|
||||
|
||||
try {
|
||||
Address recipientAddress = getRecipientAddress();
|
||||
long recipientDustThreshold = getRecipientDustThreshold();
|
||||
Long recipientAmount = sendAll ? Long.valueOf(recipientDustThreshold + 1) : getRecipientValueSats();
|
||||
if(recipientAmount != null && recipientAmount > recipientDustThreshold && (!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0))) {
|
||||
FXMLLoader paymentLoader = new FXMLLoader(AppController.class.getResource("wallet/payment.fxml"));
|
||||
tab.setContent(paymentLoader.load());
|
||||
PaymentController controller = paymentLoader.getController();
|
||||
controller.setSendController(this);
|
||||
controller.initializeView();
|
||||
tab.setUserData(controller);
|
||||
return tab;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<Payment> getPayments() {
|
||||
List<Payment> payments = new ArrayList<>();
|
||||
for(Tab tab : paymentTabs.getTabs()) {
|
||||
PaymentController controller = (PaymentController)tab.getUserData();
|
||||
payments.add(controller.getPayment());
|
||||
}
|
||||
|
||||
return payments;
|
||||
}
|
||||
|
||||
public void updateTransaction() {
|
||||
updateTransaction(null);
|
||||
}
|
||||
|
||||
public void updateTransaction(boolean sendAll) {
|
||||
try {
|
||||
if(paymentTabs.getTabs().size() == 1) {
|
||||
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
|
||||
updateTransaction(List.of(controller.getPayment(sendAll)));
|
||||
} else {
|
||||
updateTransaction(null);
|
||||
}
|
||||
} catch(IllegalStateException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
public void updateTransaction(List<Payment> transactionPayments) {
|
||||
try {
|
||||
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
|
||||
if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
|
||||
Wallet wallet = getWalletForm().getWallet();
|
||||
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
|
||||
Integer currentBlockHeight = AppController.getCurrentBlockHeight();
|
||||
boolean groupByAddress = Config.get().isGroupByAddress();
|
||||
boolean includeMempoolChange = Config.get().isIncludeMempoolChange();
|
||||
WalletTransaction walletTransaction = wallet.createWalletTransaction(getUtxoSelectors(), recipientAddress, recipientAmount, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, sendAll, groupByAddress, includeMempoolChange);
|
||||
WalletTransaction walletTransaction = wallet.createWalletTransaction(getUtxoSelectors(), getUtxoFilters(), payments, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolChange);
|
||||
walletTransactionProperty.setValue(walletTransaction);
|
||||
insufficientInputsProperty.set(false);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (InvalidAddressException e) {
|
||||
} catch(InvalidAddressException | IllegalStateException e) {
|
||||
//ignore
|
||||
} catch (InsufficientFundsException e) {
|
||||
} catch(InsufficientFundsException e) {
|
||||
insufficientInputsProperty.set(true);
|
||||
}
|
||||
|
||||
@@ -342,44 +390,19 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
}
|
||||
|
||||
Wallet wallet = getWalletForm().getWallet();
|
||||
long noInputsFee = wallet.getNoInputsFee(getRecipientAddress(), getFeeRate());
|
||||
long noInputsFee = wallet.getNoInputsFee(getPayments(), getFeeRate());
|
||||
long costOfChange = wallet.getCostOfChange(getFeeRate(), getMinimumFeeRate());
|
||||
|
||||
return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee));
|
||||
}
|
||||
|
||||
private boolean isValidRecipientAddress() {
|
||||
try {
|
||||
getRecipientAddress();
|
||||
return true;
|
||||
} catch (InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Address getRecipientAddress() throws InvalidAddressException {
|
||||
return Address.fromString(address.getText());
|
||||
}
|
||||
|
||||
private Long getRecipientValueSats() {
|
||||
return getRecipientValueSats(amountUnit.getSelectionModel().getSelectedItem());
|
||||
}
|
||||
|
||||
private Long getRecipientValueSats(BitcoinUnit bitcoinUnit) {
|
||||
if(amount.getText() != null && !amount.getText().isEmpty()) {
|
||||
double fieldValue = Double.parseDouble(amount.getText().replaceAll(",", ""));
|
||||
return bitcoinUnit.getSatsValue(fieldValue);
|
||||
private List<UtxoFilter> getUtxoFilters() {
|
||||
UtxoFilter utxoFilter = utxoFilterProperty.get();
|
||||
if(utxoFilter != null) {
|
||||
return List.of(utxoFilter);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void setRecipientValueSats(long recipientValue) {
|
||||
amount.textProperty().removeListener(amountListener);
|
||||
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
|
||||
df.setMaximumFractionDigits(8);
|
||||
amount.setText(df.format(amountUnit.getValue().getValue(recipientValue)));
|
||||
amount.textProperty().addListener(amountListener);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private Long getFeeValueSats() {
|
||||
@@ -439,10 +462,10 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
return retrievedFeeRates;
|
||||
return retrievedFeeRates;
|
||||
}
|
||||
|
||||
private Double getFeeRate() {
|
||||
public Double getFeeRate() {
|
||||
return getTargetBlocksFeeRates().get(getTargetBlocks());
|
||||
}
|
||||
|
||||
@@ -452,6 +475,10 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
|
||||
}
|
||||
|
||||
public boolean isInsufficientFeeRate() {
|
||||
return walletTransactionProperty.get() != null && walletTransactionProperty.get().getFeeRate() < AppController.getMinimumRelayFeeRate();
|
||||
}
|
||||
|
||||
private void setFeeRate(Double feeRateAmt) {
|
||||
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vByte");
|
||||
}
|
||||
@@ -460,72 +487,40 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
return targetBlocks.lookup(".thumb");
|
||||
}
|
||||
|
||||
public void setUtxoSelector(UtxoSelector utxoSelector) {
|
||||
utxoSelectorProperty.set(utxoSelector);
|
||||
}
|
||||
|
||||
public void setMaxInput(ActionEvent event) {
|
||||
UtxoSelector utxoSelector = utxoSelectorProperty.get();
|
||||
if(utxoSelector == null) {
|
||||
MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector();
|
||||
utxoSelectorProperty.set(maxUtxoSelector);
|
||||
}
|
||||
|
||||
updateTransaction(true);
|
||||
}
|
||||
|
||||
private void setFiatAmount(CurrencyRate currencyRate, Long amount) {
|
||||
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
|
||||
fiatAmount.set(currencyRate, amount);
|
||||
}
|
||||
}
|
||||
|
||||
private void setFiatFeeAmount(CurrencyRate currencyRate, Long amount) {
|
||||
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
|
||||
fiatFeeAmount.set(currencyRate, amount);
|
||||
}
|
||||
}
|
||||
|
||||
private long getRecipientDustThreshold() {
|
||||
Address address;
|
||||
try {
|
||||
address = getRecipientAddress();
|
||||
} catch(InvalidAddressException e) {
|
||||
address = new P2PKHAddress(new byte[20]);
|
||||
}
|
||||
|
||||
TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript());
|
||||
return address.getScriptType().getDustThreshold(txOutput, getFeeRate());
|
||||
}
|
||||
|
||||
public void scanQrAddress(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.uri != null) {
|
||||
if(result.uri.getAddress() != null) {
|
||||
address.setText(result.uri.getAddress().toString());
|
||||
}
|
||||
if(result.uri.getLabel() != null) {
|
||||
label.setText(result.uri.getLabel());
|
||||
}
|
||||
if(result.uri.getAmount() != null) {
|
||||
setRecipientValueSats(result.uri.getAmount());
|
||||
}
|
||||
}
|
||||
private void updateMaxClearButtons(UtxoSelector utxoSelector, UtxoFilter utxoFilter) {
|
||||
if(utxoSelector instanceof PresetUtxoSelector) {
|
||||
PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
|
||||
int num = presetUtxoSelector.getPresetUtxos().size();
|
||||
String selection = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " selected)";
|
||||
utxoLabelSelectionProperty.set(selection);
|
||||
} else if(utxoFilter instanceof ExcludeUtxoFilter) {
|
||||
ExcludeUtxoFilter excludeUtxoFilter = (ExcludeUtxoFilter)utxoFilter;
|
||||
int num = excludeUtxoFilter.getExcludedUtxos().size();
|
||||
String exclusion = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " excluded)";
|
||||
utxoLabelSelectionProperty.set(exclusion);
|
||||
} else {
|
||||
utxoLabelSelectionProperty.set("");
|
||||
}
|
||||
}
|
||||
|
||||
public void clear(ActionEvent event) {
|
||||
address.setText("");
|
||||
label.setText("");
|
||||
|
||||
amount.textProperty().removeListener(amountListener);
|
||||
amount.setText("");
|
||||
amount.textProperty().addListener(amountListener);
|
||||
|
||||
fiatAmount.setText("");
|
||||
boolean firstTab = true;
|
||||
for(Iterator<Tab> iterator = paymentTabs.getTabs().iterator(); iterator.hasNext(); ) {
|
||||
PaymentController controller = (PaymentController)iterator.next().getUserData();
|
||||
if(firstTab) {
|
||||
controller.clear();
|
||||
firstTab = false;
|
||||
} else {
|
||||
EventManager.get().unregister(controller);
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
fee.textProperty().removeListener(feeListener);
|
||||
fee.setText("");
|
||||
@@ -536,7 +531,51 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
userFeeSet.set(false);
|
||||
targetBlocks.setValue(4);
|
||||
utxoSelectorProperty.setValue(null);
|
||||
utxoFilterProperty.setValue(null);
|
||||
walletTransactionProperty.setValue(null);
|
||||
createdWalletTransactionProperty.set(null);
|
||||
|
||||
validationSupport.setErrorDecorationEnabled(false);
|
||||
}
|
||||
|
||||
public UtxoSelector getUtxoSelector() {
|
||||
return utxoSelectorProperty.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<UtxoSelector> utxoSelectorProperty() {
|
||||
return utxoSelectorProperty;
|
||||
}
|
||||
|
||||
public boolean isInsufficientInputs() {
|
||||
return insufficientInputsProperty.get();
|
||||
}
|
||||
|
||||
public BooleanProperty insufficientInputsProperty() {
|
||||
return insufficientInputsProperty;
|
||||
}
|
||||
|
||||
public WalletTransaction getWalletTransaction() {
|
||||
return walletTransactionProperty.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<WalletTransaction> walletTransactionProperty() {
|
||||
return walletTransactionProperty;
|
||||
}
|
||||
|
||||
public String getUtxoLabelSelection() {
|
||||
return utxoLabelSelectionProperty.get();
|
||||
}
|
||||
|
||||
public StringProperty utxoLabelSelectionProperty() {
|
||||
return utxoLabelSelectionProperty;
|
||||
}
|
||||
|
||||
public TabPane getPaymentTabs() {
|
||||
return paymentTabs;
|
||||
}
|
||||
|
||||
public Button getCreateButton() {
|
||||
return createButton;
|
||||
}
|
||||
|
||||
private void revalidate(TextField field, ChangeListener<String> listener) {
|
||||
@@ -548,8 +587,32 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
}
|
||||
|
||||
public void createTransaction(ActionEvent event) {
|
||||
if(log.isDebugEnabled()) {
|
||||
Map<WalletNode, String> nodeHashes = walletTransactionProperty.get().getSelectedUtxos().values().stream().collect(Collectors.toMap(Function.identity(), node -> ElectrumServer.getScriptHash(walletForm.getWallet(), node)));
|
||||
Map<WalletNode, String> changeHash = Collections.emptyMap();
|
||||
if(walletTransactionProperty.get().getChangeNode() != null) {
|
||||
changeHash = Map.of(walletTransactionProperty.get().getChangeNode(), ElectrumServer.getScriptHash(walletForm.getWallet(), walletTransactionProperty.get().getChangeNode()));
|
||||
}
|
||||
log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + nodeHashes + " and \nchange \n" + changeHash);
|
||||
}
|
||||
|
||||
addWalletTransactionNodes();
|
||||
createdWalletTransactionProperty.set(walletTransactionProperty.get());
|
||||
PSBT psbt = walletTransactionProperty.get().createPSBT();
|
||||
EventManager.get().post(new ViewPSBTEvent(label.getText(), psbt));
|
||||
EventManager.get().post(new ViewPSBTEvent(walletTransactionProperty.get().getPayments().get(0).getLabel(), psbt));
|
||||
}
|
||||
|
||||
private void addWalletTransactionNodes() {
|
||||
WalletTransaction walletTransaction = walletTransactionProperty.get();
|
||||
Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
|
||||
if(walletTransaction.getChangeNode() != null) {
|
||||
nodes.add(walletTransaction.getChangeNode());
|
||||
}
|
||||
List<WalletNode> consolidationNodes = walletTransaction.getConsolidationSendNodes();
|
||||
nodes.addAll(consolidationNodes);
|
||||
|
||||
//All wallet nodes applicable to this transaction are stored so when the subscription status for one is updated, the history for all can be fetched in one atomic update
|
||||
walletForm.addWalletTransactionNodes(nodes);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
@@ -561,11 +624,35 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
|
||||
@Subscribe
|
||||
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
|
||||
if(event.getWallet().equals(walletForm.getWallet())) {
|
||||
updateTransaction();
|
||||
if(event.getWallet().equals(walletForm.getWallet()) && createdWalletTransactionProperty.get() != null) {
|
||||
if(createdWalletTransactionProperty.get().getSelectedUtxos() != null && allSelectedUtxosSpent(event.getHistoryChangedNodes())) {
|
||||
clear(null);
|
||||
} else {
|
||||
updateTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allSelectedUtxosSpent(List<WalletNode> historyChangedNodes) {
|
||||
Set<BlockTransactionHashIndex> unspentUtxos = new HashSet<>(createdWalletTransactionProperty.get().getSelectedUtxos().keySet());
|
||||
|
||||
for(Map.Entry<BlockTransactionHashIndex, WalletNode> selectedUtxoEntry : createdWalletTransactionProperty.get().getSelectedUtxos().entrySet()) {
|
||||
BlockTransactionHashIndex utxo = selectedUtxoEntry.getKey();
|
||||
WalletNode utxoWalletNode = selectedUtxoEntry.getValue();
|
||||
|
||||
for(WalletNode changedNode : historyChangedNodes) {
|
||||
if(utxoWalletNode.equals(changedNode)) {
|
||||
Optional<BlockTransactionHashIndex> spentTxo = changedNode.getTransactionOutputs().stream().filter(txo -> txo.getHash().equals(utxo.getHash()) && txo.getIndex() == utxo.getIndex() && txo.isSpent()).findAny();
|
||||
if(spentTxo.isPresent()) {
|
||||
unspentUtxos.remove(utxo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unspentUtxos.isEmpty();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void walletEntryLabelChanged(WalletEntryLabelChangedEvent event) {
|
||||
if(event.getWallet().equals(walletForm.getWallet())) {
|
||||
@@ -584,32 +671,61 @@ public class SendController extends WalletFormController implements Initializabl
|
||||
public void spendUtxos(SpendUtxoEvent event) {
|
||||
if(!event.getUtxoEntries().isEmpty() && event.getUtxoEntries().get(0).getWallet().equals(getWalletForm().getWallet())) {
|
||||
List<BlockTransactionHashIndex> utxos = event.getUtxoEntries().stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList());
|
||||
setUtxoSelector(new PresetUtxoSelector(utxos));
|
||||
utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
|
||||
utxoFilterProperty.set(null);
|
||||
updateTransaction(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
|
||||
BitcoinUnit unit = event.getBitcoinUnit();
|
||||
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
|
||||
unit = getWalletForm().getWallet().getAutoUnit();
|
||||
}
|
||||
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
||||
BitcoinUnit unit = getBitcoinUnit(event.getBitcoinUnit());
|
||||
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
|
||||
if(event.getExchangeSource() == ExchangeSource.NONE) {
|
||||
fiatAmount.setCurrency(null);
|
||||
fiatAmount.setBtcRate(0.0);
|
||||
fiatFeeAmount.setCurrency(null);
|
||||
fiatFeeAmount.setBtcRate(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
|
||||
setFiatAmount(event.getCurrencyRate(), getRecipientValueSats());
|
||||
setFiatFeeAmount(event.getCurrencyRate(), getFeeValueSats());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void excludeUtxo(ExcludeUtxoEvent event) {
|
||||
if(event.getWalletTransaction() == walletTransactionProperty.get()) {
|
||||
UtxoSelector utxoSelector = utxoSelectorProperty.get();
|
||||
if(utxoSelector instanceof MaxUtxoSelector) {
|
||||
Collection<BlockTransactionHashIndex> utxos = walletForm.getWallet().getWalletUtxos().keySet();
|
||||
utxos.remove(event.getUtxo());
|
||||
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
|
||||
ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
|
||||
utxos.removeAll(existingUtxoFilter.getExcludedUtxos());
|
||||
}
|
||||
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(utxos);
|
||||
utxoSelectorProperty.set(presetUtxoSelector);
|
||||
updateTransaction(true);
|
||||
} else if(utxoSelector instanceof PresetUtxoSelector) {
|
||||
PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
|
||||
presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
|
||||
utxoSelectorProperty.set(presetUtxoSelector);
|
||||
updateTransaction(true);
|
||||
} else {
|
||||
ExcludeUtxoFilter utxoFilter = new ExcludeUtxoFilter();
|
||||
if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
|
||||
ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
|
||||
utxoFilter.getExcludedUtxos().addAll(existingUtxoFilter.getExcludedUtxos());
|
||||
}
|
||||
|
||||
utxoFilter.getExcludedUtxos().add(event.getUtxo());
|
||||
utxoFilterProperty.set(utxoFilter);
|
||||
updateTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.wallet;
|
||||
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.sparrowwallet.drongo.OutputDescriptor;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.crypto.*;
|
||||
import com.sparrowwallet.drongo.policy.Policy;
|
||||
@@ -12,14 +13,15 @@ import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppController;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.control.CopyableLabel;
|
||||
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
||||
import com.sparrowwallet.sparrow.control.*;
|
||||
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.fxml.Initializable;
|
||||
@@ -46,7 +48,7 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
private ComboBox<PolicyType> policyType;
|
||||
|
||||
@FXML
|
||||
private TextField spendingMiniscript;
|
||||
private DescriptorArea descriptor;
|
||||
|
||||
@FXML
|
||||
private ComboBox<ScriptType> scriptType;
|
||||
@@ -86,7 +88,7 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
@Override
|
||||
public void initializeView() {
|
||||
keystoreTabs = new TabPane();
|
||||
keystoreTabsPane.getChildren().add(Borders.wrap(keystoreTabs).etchedBorder().outerPadding(10, 5, 0 ,0).innerPadding(0).raised().buildAll());
|
||||
keystoreTabsPane.getChildren().add(keystoreTabs);
|
||||
|
||||
policyType.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, policyType) -> {
|
||||
walletForm.getWallet().setPolicyType(policyType);
|
||||
@@ -160,6 +162,8 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
}
|
||||
});
|
||||
|
||||
initializeDescriptorField(descriptor);
|
||||
|
||||
revert.setOnAction(event -> {
|
||||
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
|
||||
totalKeystores.unbind();
|
||||
@@ -172,7 +176,7 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
apply.setOnAction(event -> {
|
||||
revert.setDisable(true);
|
||||
apply.setDisable(true);
|
||||
saveWallet();
|
||||
saveWallet(false);
|
||||
});
|
||||
|
||||
setFieldsFromWallet(walletForm.getWallet());
|
||||
@@ -224,6 +228,7 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
KeystoreController controller = keystoreLoader.getController();
|
||||
controller.setKeystore(getWalletForm(), keystore);
|
||||
tab.textProperty().bind(controller.getLabel().textProperty());
|
||||
tab.setUserData(keystore);
|
||||
|
||||
controller.getValidationSupport().validationResultProperty().addListener((o, oldValue, result) -> {
|
||||
if(result.getErrors().isEmpty()) {
|
||||
@@ -243,6 +248,71 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
}
|
||||
}
|
||||
|
||||
public void scanDescriptorQR(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.outputDescriptor != null) {
|
||||
setDescriptorText(result.outputDescriptor.toString());
|
||||
} else if(result.payload != null && !result.payload.isEmpty()) {
|
||||
setDescriptorText(result.payload);
|
||||
} else if(result.exception != null) {
|
||||
AppController.showErrorDialog("Error scanning QR", result.exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void editDescriptor(ActionEvent event) {
|
||||
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
|
||||
String outputDescriptorString = outputDescriptor.toString(walletForm.getWallet().isValid());
|
||||
|
||||
TextAreaDialog dialog = new TextAreaDialog(outputDescriptorString);
|
||||
dialog.setTitle("Edit wallet output descriptor");
|
||||
dialog.getDialogPane().setHeaderText("Wallet output descriptor:");
|
||||
Optional<String> text = dialog.showAndWait();
|
||||
if(text.isPresent() && !text.get().isEmpty() && !text.get().equals(outputDescriptorString)) {
|
||||
setDescriptorText(text.get());
|
||||
}
|
||||
}
|
||||
|
||||
private void setDescriptorText(String text) {
|
||||
try {
|
||||
OutputDescriptor editedOutputDescriptor = OutputDescriptor.getOutputDescriptor(text.trim().replace("\\", ""));
|
||||
Wallet editedWallet = editedOutputDescriptor.toWallet();
|
||||
|
||||
editedWallet.setName(getWalletForm().getWallet().getName());
|
||||
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
|
||||
totalKeystores.unbind();
|
||||
totalKeystores.setValue(0);
|
||||
walletForm.setWallet(editedWallet);
|
||||
initialising = true;
|
||||
setFieldsFromWallet(editedWallet);
|
||||
|
||||
EventManager.get().post(new SettingsChangedEvent(editedWallet, SettingsChangedEvent.Type.POLICY));
|
||||
} catch(Exception e) {
|
||||
AppController.showErrorDialog("Invalid output descriptor", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void showAdvanced(ActionEvent event) {
|
||||
AdvancedDialog advancedDialog = new AdvancedDialog(walletForm.getWallet());
|
||||
advancedDialog.showAndWait();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String describeKeystore(Keystore keystore) {
|
||||
if(!keystore.isValid()) {
|
||||
for(Tab tab : keystoreTabs.getTabs()) {
|
||||
if(tab.getUserData() == keystore && tab.getTooltip() != null) {
|
||||
return tab.getTooltip().getText();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.describeKeystore(keystore);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void update(SettingsChangedEvent event) {
|
||||
Wallet wallet = event.getWallet();
|
||||
@@ -253,18 +323,22 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), (int)multisigControl.getLowValue()));
|
||||
}
|
||||
|
||||
spendingMiniscript.setText(wallet.getDefaultPolicy().getMiniscript().getScript());
|
||||
descriptor.setWallet(wallet);
|
||||
revert.setDisable(false);
|
||||
apply.setDisable(!wallet.isValid());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveWallet() {
|
||||
private void saveWallet(boolean changePassword) {
|
||||
ECKey existingPubKey = walletForm.getStorage().getEncryptionPubKey();
|
||||
|
||||
WalletPasswordDialog.PasswordRequirement requirement;
|
||||
if(existingPubKey == null) {
|
||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_NEW;
|
||||
if(changePassword) {
|
||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_CHANGE;
|
||||
} else {
|
||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_NEW;
|
||||
}
|
||||
} else if(Storage.NO_PASSWORD_KEY.equals(existingPubKey)) {
|
||||
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_EMPTY;
|
||||
} else {
|
||||
@@ -290,6 +364,9 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
try {
|
||||
walletForm.getStorage().setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
|
||||
walletForm.saveAndRefresh();
|
||||
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW) {
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error saving wallet", e);
|
||||
AppController.showErrorDialog("Error saving wallet", e.getMessage());
|
||||
@@ -313,11 +390,20 @@ public class SettingsController extends WalletFormController implements Initiali
|
||||
return;
|
||||
}
|
||||
|
||||
if(dlg.isChangePassword()) {
|
||||
walletForm.getStorage().setEncryptionPubKey(null);
|
||||
saveWallet(true);
|
||||
return;
|
||||
}
|
||||
|
||||
key = new Key(encryptionFullKey.getPrivKeyBytes(), walletForm.getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
|
||||
walletForm.getWallet().encrypt(key);
|
||||
|
||||
walletForm.getStorage().setEncryptionPubKey(encryptionPubKey);
|
||||
walletForm.saveAndRefresh();
|
||||
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW) {
|
||||
EventManager.get().post(new RequestOpenWalletsEvent());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving wallet", e);
|
||||
AppController.showErrorDialog("Error saving wallet", e.getMessage());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user