Compare commits

...
49 Commits
Author SHA1 Message Date
Craig Raw b50cf77f3b rename xPub to xpub 2020-10-07 13:11:46 +02:00
Craig Raw 5e8148e5b9 v0.9.5 2020-10-07 12:37:10 +02:00
Craig Raw fa5ac7917b add cobo vault singlesig and multisig file import and export 2020-10-07 12:31:58 +02:00
Craig Raw eda712e269 add comment to transaction entry equals 2020-10-06 15:10:24 +02:00
Craig Raw 3c6adafc15 tweak help copy and ui 2020-10-06 14:50:25 +02:00
Craig Raw 639e145672 use tPubs instead of xPubs on labels in testnet 2020-10-06 12:39:30 +02:00
Craig Raw eb8d66bf25 add utility buttons to import and export xpubs with qr codes, show slip132 versions 2020-10-05 17:32:30 +02:00
Craig Raw 3a885b3a28 improve open wallet error dialog message 2020-10-05 12:33:05 +02:00
Craig Raw 7e93b6b198 fix byteword list as per https://github.com/BlockchainCommons/bc-bytewords/commit/9f7ec3c73c39c9638c581beb056ed03c5a3be9ff 2020-10-05 11:39:28 +02:00
Craig Raw f949bcc1cf attempt to import wallet file if open fails 2020-10-05 10:36:27 +02:00
Craig Raw 9e5937d2fe check history on all created tx script hashes when one is updated 2020-10-02 15:18:12 +02:00
Craig Raw 82a8f08440 upgrade to javafx 15, upgrade gradle plugins, ensure reproducible builds 2020-10-01 14:09:36 +02:00
Craig Raw c806959199 add doc for updating submodules 2020-10-01 12:04:41 +02:00
Craig Raw f7fb62fcea fix on windows 2020-10-01 12:00:38 +02:00
Craig Raw ea2783e51b testnet related changes and network improvements and logging 2020-10-01 09:11:02 +02:00
Craig Raw ff7a35e68b select alternative network, support testnet on hwi, other fixes 2020-09-29 15:45:22 +02:00
Craig Raw c68cda5b2f handle support for alternative (non-mainnet) networks 2020-09-28 14:32:02 +02:00
Craig Raw 2cb667a671 shorten tooltip delay, set tooltip font size equal 2020-09-26 12:43:51 +02:00
Craig Raw eb09076604 exclude utxos from transaction diagram 2020-09-26 12:30:33 +02:00
Craig Raw 32bc9f6a99 dont refresh invalid wallets 2020-09-25 17:56:57 +02:00
Craig Raw dc8a8dd857 copy non-xpub value 2020-09-25 17:48:24 +02:00
Craig Raw d5a26fdf5d followup to prev commit to handle button disable 2020-09-25 15:37:11 +02:00
Craig Raw e48e40da0d retrieve min fee rate, adjust block target rates and dont create txes with insufficient fee rate 2020-09-25 15:22:52 +02:00
Craig Raw e878c4ea78 hide validation error on clear send 2020-09-25 14:07:16 +02:00
Craig Raw ead8fa2207 clear send details on mempool tx, edit wallet output descriptor 2020-09-25 13:43:55 +02:00
Craig Raw 9a395f5dd1 v0.9.4 2020-09-21 10:00:35 +02:00
Craig Raw a99114cc6f dynamic blockchain form updating, minor tweaks 2020-09-21 09:06:01 +02:00
Craig Raw ea36c63ebb version check: test against predefined address 2020-09-20 15:09:54 +02:00
Craig Raw 77d3c848f9 add dark theme 2020-09-20 15:04:53 +02:00
Craig Raw f22312e04f add software update check 2020-09-18 15:48:05 +02:00
Craig Raw b9db4421df import transaction labels from electrum 2020-09-18 12:53:05 +02:00
Craig Raw b7aa73e99f show wallet loading progress 2020-09-18 10:50:41 +02:00
Craig Raw 4bad46c9e3 optimize electrum server queries 2020-09-17 15:10:56 +02:00
Craig Raw 084a3c76af start with simple RPC for maximum compatibility 2020-09-15 12:07:37 +02:00
Craig Raw 2e0ca1b4fa add built in tor 2020-09-14 10:51:38 +02:00
Craig Raw 4bc446724d fix keepkey hww 2020-09-10 11:40:21 +02:00
Craig Raw 0c56f5a9a1 add change password functionality 2020-09-09 16:15:33 +02:00
Craig Raw 38b27bb091 configurable wallet gap limit 2020-09-09 14:56:50 +02:00
Craig Raw b8c3bf1bea improve wallet descriptor and add output descriptor retrieval 2020-09-09 11:18:13 +02:00
Craig Raw e65f1ef3cc export specter with checksum 2020-09-08 12:42:15 +02:00
Craig Raw f25d2618b6 descriptor change multi to sortedmulti 2020-09-08 08:31:42 +02:00
Craig Raw 0adf94bf62 initial specter wallet import and export functionality 2020-09-07 17:23:12 +02:00
Craig Raw 7d7c5e5064 Support replacing existing keystore with watch only keystore 2020-09-04 13:00:40 +02:00
Craig Raw 10e4d8843d change bip39 confirmation wording 2020-09-03 20:54:46 +02:00
Craig Raw 6e10784e49 support usb message signing 2020-09-03 20:46:44 +02:00
Craig Raw 885efd7985 support bip 137 for message signing, but handle electrum approach as well when verifying 2020-09-03 19:26:54 +02:00
Craig Raw ec9534f8ae version 0.9.3 release 2020-09-03 13:41:12 +02:00
Craig Raw 6c90a7e0ba add message signing/verifying functionality 2020-09-03 13:36:43 +02:00
Craig Raw 9dd52f120f add ur toString 2020-09-03 09:38:16 +02:00
129 changed files with 3308 additions and 493 deletions
+35 -3
View File
@@ -8,23 +8,53 @@ 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`
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 +62,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.
+14 -9
View File
@@ -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.2'
def sparrowVersion = '0.9.5'
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,7 @@ repositories {
mavenCentral()
maven { url 'https://oss.sonatype.org/content/groups/public' }
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
maven { url 'https://jitpack.io' }
}
tasks.withType(AbstractArchiveTask) {
@@ -27,7 +28,7 @@ tasks.withType(AbstractArchiveTask) {
}
javafx {
version = "14"
version = "15"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -54,6 +55,7 @@ dependencies {
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 +82,8 @@ compileJava {
processResources {
doLast {
delete fileTree("$buildDir/resources/main/external").matching {
exclude "$osName/**"
delete fileTree("$buildDir/resources/main/native").matching {
exclude "${osName}/**"
}
}
}
@@ -101,7 +103,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 +121,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 +143,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 +165,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: c7e16a29e3...4b6c477030
Executable
+11
View File
@@ -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
+8
View File
@@ -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.2</string>
<string>0.9.5</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -4,6 +4,7 @@ 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.crypto.InvalidPasswordException;
@@ -19,6 +20,7 @@ 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;
@@ -70,8 +72,8 @@ public class AppController implements Initializable {
private static final int SERVER_PING_PERIOD = 10 * 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 +99,9 @@ public class AppController implements Initializable {
@FXML
private ToggleGroup bitcoinUnit;
@FXML
private ToggleGroup theme;
@FXML
private CheckMenuItem showTxHex;
@@ -125,12 +130,16 @@ 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;
@@ -221,8 +230,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 +266,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 +296,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 +313,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 +356,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 +406,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 +425,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) {
@@ -590,6 +646,10 @@ public class AppController implements Initializable {
return targetBlockFeeRates;
}
public static Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate;
}
public static CurrencyRate getFiatCurrencyExchangeRate() {
return fiatCurrencyExchangeRate;
}
@@ -615,6 +675,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 +685,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 +771,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 +791,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 +807,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 +820,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,35 +880,65 @@ 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.getWalletFile(wallet.getName());
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;
}
//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);
}
Storage storage = new Storage(walletFile);
Tab tab = addWalletTab(storage, wallet);
tabs.getSelectionModel().select(tab);
}
public void exportWallet(ActionEvent event) {
@@ -848,6 +959,27 @@ public class AppController implements Initializable {
preferencesDialog.showAndWait();
}
public void signVerifyMessage(ActionEvent event) {
MessageSignDialog messageSignDialog = null;
Tab tab = tabs.getSelectionModel().getSelectedItem();
if(tab != null && tab.getUserData() instanceof WalletTabData) {
WalletTabData walletTabData = (WalletTabData)tab.getUserData();
Wallet wallet = walletTabData.getWallet();
if(wallet.getKeystores().size() == 1 &&
(wallet.getKeystores().get(0).hasSeed() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
//Can sign and verify
messageSignDialog = new MessageSignDialog(wallet);
}
}
if(messageSignDialog == null) {
//Can verify only
messageSignDialog = new MessageSignDialog();
}
messageSignDialog.showAndWait();
}
public Tab addWalletTab(Storage storage, Wallet wallet) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
@@ -1031,6 +1163,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();
@@ -1134,7 +1279,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("");
@@ -1143,6 +1288,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) {
@@ -1186,7 +1345,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());
}
@@ -1199,6 +1358,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));
@@ -1257,9 +1417,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,5 +1,7 @@
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;
@@ -17,18 +19,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.2";
public static final String APP_VERSION = "0.9.5";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
private Stage mainStage;
@@ -107,7 +111,37 @@ 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);
}
}
}
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
}
@@ -32,7 +32,7 @@ public class AddressCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
Address address = utxoEntry.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor()));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), null));
Tooltip tooltip = new Tooltip();
tooltip.setText(getTooltipText(utxoEntry));
setTooltip(tooltip);
@@ -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;");
}
}
}
@@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
@@ -38,6 +39,7 @@ public class DevicePane extends TitledDescriptionPane {
private final Wallet wallet;
private final PSBT psbt;
private final KeyDerivation keyDerivation;
private final String message;
private final Device device;
private CustomPasswordField pinField;
@@ -47,6 +49,7 @@ public class DevicePane extends TitledDescriptionPane {
private SplitMenuButton importButton;
private Button signButton;
private Button displayAddressButton;
private Button signMessageButton;
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
@@ -56,6 +59,7 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
setDefaultStatus();
@@ -81,6 +85,7 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = null;
this.psbt = psbt;
this.keyDerivation = null;
this.message = null;
this.device = device;
setDefaultStatus();
@@ -106,6 +111,7 @@ public class DevicePane extends TitledDescriptionPane {
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = keyDerivation;
this.message = null;
this.device = device;
setDefaultStatus();
@@ -125,6 +131,32 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
}
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = keyDerivation;
this.message = message;
this.device = device;
setDefaultStatus();
showHideLink.setVisible(false);
createSetPassphraseButton();
createSignMessageButton();
if (device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else {
showOperationButton();
}
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
@Override
protected Control createButton() {
createUnlockButton();
@@ -209,8 +241,24 @@ public class DevicePane extends TitledDescriptionPane {
}
}
private void createSignMessageButton() {
signMessageButton = new Button("Sign Message");
signMessageButton.setDefaultButton(true);
signMessageButton.setAlignment(Pos.CENTER_RIGHT);
signMessageButton.setOnAction(event -> {
signMessageButton.setDisable(true);
signMessage();
});
signMessageButton.managedProperty().bind(signMessageButton.visibleProperty());
signMessageButton.setVisible(false);
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
signMessageButton.setDisable(true);
}
}
private void unlock(Device device) {
if(device.getModel().equals(WalletModel.TREZOR_1)) {
if(device.getModel().requiresPinPrompt()) {
promptPin();
}
}
@@ -438,6 +486,20 @@ public class DevicePane extends TitledDescriptionPane {
displayAddressService.start();
}
private void signMessage() {
Hwi.SignMessageService signMessageService = new Hwi.SignMessageService(device, passphrase.get(), message, keyDerivation.getDerivationPath());
signMessageService.setOnSucceeded(successEvent -> {
String signature = signMessageService.getValue();
EventManager.get().post(new MessageSignedEvent(wallet, signature));
});
signMessageService.setOnFailed(failedEvent -> {
setError("Could not sign message", signMessageService.getException().getMessage());
signMessageButton.setDisable(false);
});
setDescription("Signing message...");
signMessageService.start();
}
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
importButton.setVisible(true);
@@ -450,6 +512,9 @@ public class DevicePane extends TitledDescriptionPane {
} else if(deviceOperation.equals(DeviceOperation.DISPLAY_ADDRESS)) {
displayAddressButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.SIGN_MESSAGE)) {
signMessageButton.setVisible(true);
showHideLink.setVisible(false);
}
}
@@ -490,6 +555,6 @@ public class DevicePane extends TitledDescriptionPane {
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS;
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE;
}
}
@@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.MessageSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
public class DeviceSignMessageDialog extends DeviceDialog<String> {
private final Wallet wallet;
private final String message;
private final KeyDerivation keyDerivation;
public DeviceSignMessageDialog(List<String> operationFingerprints, Wallet wallet, String message, KeyDerivation keyDerivation) {
super(operationFingerprints);
this.wallet = wallet;
this.message = message;
this.keyDerivation = keyDerivation;
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
});
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, message, keyDerivation, device);
}
@Subscribe
public void messageSigned(MessageSignedEvent event) {
if(wallet == event.getWallet()) {
setResult(event.getSignature());
}
}
}
@@ -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();
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -71,12 +72,13 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor()));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), null));
Tooltip tooltip = new Tooltip();
tooltip.setText(nodeEntry.getNode().getDerivationPath());
setTooltip(tooltip);
getStyleClass().add("address-cell");
HBox actionBox = new HBox();
Button receiveButton = new Button("");
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
receiveGlyph.setFontSize(12);
@@ -85,7 +87,23 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
setGraphic(receiveButton);
actionBox.getChildren().add(receiveButton);
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
(nodeEntry.getWallet().getKeystores().get(0).hasSeed() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
Button signMessageButton = new Button("");
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
signMessageButton.setGraphic(signMessageGlyph);
signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
actionBox.getChildren().add(signMessageButton);
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
}
setGraphic(actionBox);
} else if(entry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
setText(hashIndexEntry.getDescription());
@@ -178,7 +196,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor) {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(AE -> {
hide();
@@ -204,6 +222,17 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
}
}
@@ -53,7 +53,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
fileChooser.setTitle("Open " + importer.getWalletModel().toDisplayString() + " File");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", "*.*"),
new FileChooser.ExtensionFilter("JSON", "*.json")
new FileChooser.ExtensionFilter("JSON", "*.json"),
new FileChooser.ExtensionFilter("TXT", "*.txt")
);
File file = fileChooser.showOpenDialog(window);
@@ -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() {
@@ -0,0 +1,329 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.ECKey;
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.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.application.Platform;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.security.SignatureException;
import java.util.List;
import java.util.Optional;
import static com.sparrowwallet.sparrow.AppController.setStageIcon;
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
private final TextField address;
private final TextArea message;
private final TextArea signature;
private final Wallet wallet;
private WalletNode walletNode;
/**
* Verification only constructor
*/
public MessageSignDialog() {
this(null, null);
}
/**
* Sign and verify with user entered address
*
* @param wallet Wallet to sign with
*/
public MessageSignDialog(Wallet wallet) {
this(wallet, null);
}
/**
* Sign and verify with preset address
*
* @param wallet Wallet to sign with
* @param walletNode Wallet node to derive address from
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode) {
if(wallet != null) {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasSeed() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore");
}
}
this.wallet = wallet;
this.walletNode = walletNode;
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm());
AppController.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(wallet == null ? "Verify Message" : "Sign/Verify Message");
Image image = new Image("image/seed.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
VBox vBox = new VBox();
vBox.setSpacing(20);
Form form = new Form();
Fieldset fieldset = new Fieldset();
fieldset.setText("");
Field addressField = new Field();
addressField.setText("Address:");
address = new TextField();
address.getStyleClass().add("id");
address.setEditable(walletNode == null);
addressField.getInputs().add(address);
if(walletNode != null) {
address.setText(wallet.getAddress(walletNode).toString());
}
Field messageField = new Field();
messageField.setText("Message:");
message = new TextArea();
message.setWrapText(true);
message.setPrefRowCount(10);
message.setStyle("-fx-pref-height: 180px");
messageField.getInputs().add(message);
Field signatureField = new Field();
signatureField.setText("Signature:");
signature = new TextArea();
signature.getStyleClass().add("id");
signature.setPrefRowCount(2);
signature.setStyle("-fx-pref-height: 60px");
signature.setWrapText(true);
signatureField.getInputs().add(signature);
fieldset.getChildren().addAll(addressField, messageField, signatureField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
Button signButton = (Button)dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setOnAction(event -> {
signMessage();
});
Button verifyButton = (Button)dialogPane.lookupButton(verifyButtonType);
verifyButton.setDefaultButton(false);
verifyButton.setOnAction(event -> {
verifyMessage();
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
} catch(InvalidAddressException e) {
//can't happen
}
}
});
EventManager.get().register(this);
setOnCloseRequest(event -> {
if(ButtonBar.ButtonData.APPLY.equals(getResult())) {
event.consume();
return;
}
EventManager.get().unregister(this);
});
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
}
private Address getAddress()throws InvalidAddressException {
return Address.fromString(address.getText());
}
private boolean isValidAddress() {
try {
getAddress();
return true;
} catch (InvalidAddressException e) {
return false;
}
}
private void setWalletNodeFromAddress(Wallet wallet, Address address) {
walletNode = wallet.getWalletAddresses().get(address);
}
private void signMessage() {
if(walletNode == null) {
AppController.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
return;
}
if(wallet.containsSeeds()) {
if(wallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
signUnencryptedKeystore(wallet);
}
} else if(wallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(wallet);
}
}
private void signUnencryptedKeystore(Wallet decryptedWallet) {
try {
//Note we can expect a single keystore due to the check above
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
signature.clear();
signature.appendText(signatureText);
} catch(Exception e) {
log.error("Could not sign message", e);
AppController.showErrorDialog("Could not sign message", e.getMessage());
}
}
private void signUsbKeystore(Wallet usbWallet) {
List<String> fingerprints = List.of(usbWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = usbWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, usbWallet, message.getText().trim(), fullDerivation);
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
if(optSignature.isPresent()) {
signature.clear();
signature.appendText(optSignature.get());
}
}
private void verifyMessage() {
try {
//Find ECKey from message and signature
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
boolean verified = false;
try {
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), false);
verified = verifyMessage(signedMessageKey);
} catch(SignatureException e) {
//ignore
}
if(!verified) {
try {
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
verified = verifyMessage(electrumSignedMessageKey);
} catch(SignatureException e) {
//ignore
}
}
if(verified) {
Alert alert = new Alert(Alert.AlertType.INFORMATION);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.setTitle("Verification Succeeded");
alert.setHeaderText("Verification Succeeded");
alert.setContentText("The signature verified against the message.");
alert.showAndWait();
} else {
AppController.showErrorDialog("Verification failed", "The provided signature did not match the message for this address.");
}
} catch(IllegalArgumentException e) {
AppController.showErrorDialog("Could not verify message", e.getMessage());
} catch(Exception e) {
log.error("Could not verify message", e);
AppController.showErrorDialog("Could not verify message", e.getMessage());
}
}
private boolean verifyMessage(ECKey signedMessageKey) throws InvalidAddressException, SignatureException {
Address providedAddress = getAddress();
ScriptType scriptType = providedAddress.getScriptType();
if(scriptType == ScriptType.P2SH) {
scriptType = ScriptType.P2SH_P2WPKH;
}
if(!ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).contains(scriptType)) {
throw new IllegalArgumentException("Only single signature P2PKH, P2SH-P2WPKH or P2WPKH addresses can verify messages.");
}
Address signedMessageAddress = scriptType.getAddress(signedMessageKey);
return providedAddress.equals(signedMessageAddress);
}
@Subscribe
public void openWallets(OpenWalletsEvent event) {
Storage storage = event.getStorage(wallet);
if(storage == null) {
throw new IllegalStateException("Wallet " + wallet + " without Storage");
}
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
signUnencryptedKeystore(decryptedWallet);
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
AppController.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
}
}
@@ -188,7 +188,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
AnchorPane.setTopAnchor(invalidLabel, 5.0);
AnchorPane.setLeftAnchor(invalidLabel, 0.0);
confirmButton = new Button("Confirm Written Backup");
confirmButton = new Button("Re-enter Words...");
confirmButton.setOnAction(event -> {
confirmBackup();
});
@@ -269,7 +269,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
private void displayMnemonicCode() {
setDescription("Write down word list to confirm backup");
setDescription("Write down words before re-entering");
showHideLink.setVisible(false);
calculateButton.setVisible(false);
@@ -54,7 +54,7 @@ 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()) {
@@ -76,6 +76,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,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamResolution;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Base43;
@@ -40,7 +41,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 -> {
@@ -106,6 +107,15 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
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);
@@ -180,6 +190,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final Transaction transaction;
public final PSBT psbt;
public final BitcoinURI uri;
public final ExtendedKey extendedKey;
public final String error;
public final Throwable exception;
@@ -187,6 +198,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.transaction = transaction;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.exception = null;
}
@@ -195,6 +207,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.transaction = null;
this.psbt = psbt;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.exception = null;
}
@@ -203,6 +216,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.transaction = null;
this.psbt = null;
this.uri = uri;
this.extendedKey = null;
this.error = null;
this.exception = null;
}
@@ -211,6 +225,16 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.transaction = null;
this.psbt = null;
this.uri = BitcoinURI.fromAddress(address);
this.extendedKey = null;
this.error = null;
this.exception = null;
}
public Result(ExtendedKey extendedKey) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = extendedKey;
this.error = null;
this.exception = null;
}
@@ -219,6 +243,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = error;
this.exception = null;
}
@@ -227,6 +252,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.exception = exception;
}
@@ -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;
@@ -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() {
@@ -4,16 +4,21 @@ import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
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;
@@ -22,6 +27,7 @@ import java.util.*;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
private static final double DIAGRAM_HEIGHT = 230.0;
private static final int TOOLTIP_SHOW_DELAY = 50;
private WalletTransaction walletTx;
@@ -146,14 +152,24 @@ 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));
});
label.setGraphic(excludeUtxoButton);
label.setContentDisplay(ContentDisplay.LEFT);
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.getStyleClass().add("input-label");
if(input.getLabel() == null || input.getLabel().isEmpty()) {
label.getStyleClass().add("input-label");
} else {
tooltip.getStyleClass().add("input-label");
}
} else {
AdditionalBlockTransactionHashIndex additionalReference = (AdditionalBlockTransactionHashIndex)input;
@@ -164,6 +180,7 @@ public class TransactionDiagram extends GridPane {
tooltip.setText(joiner.toString());
tooltip.getStyleClass().add("input-label");
}
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
label.setTooltip(tooltip);
inputsBox.getChildren().add(label);
@@ -275,6 +292,8 @@ public class TransactionDiagram extends GridPane {
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());
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientLabel.setTooltip(recipientTooltip);
outputsBox.getChildren().add(recipientLabel);
outputsBox.getChildren().add(createSpacer());
@@ -284,6 +303,8 @@ public class TransactionDiagram extends GridPane {
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());
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 +316,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());
@@ -324,6 +346,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,35 +361,35 @@ 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 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;
@@ -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;
}
}
@@ -0,0 +1,25 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
/**
* This event is used by the DeviceSignMessageDialog to indicate that a USB device has signed a message
*
*/
public class MessageSignedEvent {
private final Wallet wallet;
private final String signature;
public MessageSignedEvent(Wallet wallet, String signature) {
this.wallet = wallet;
this.signature = signature;
}
public Wallet getWallet() {
return wallet;
}
public String getSignature() {
return signature;
}
}
@@ -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'),
@@ -40,6 +41,7 @@ public class FontAwesome5 extends GlyphFont {
SATELLITE_DISH('\uf7c0'),
SD_CARD('\uf7c2'),
SEARCH('\uf002'),
TIMES_CIRCLE('\uf057'),
TOOLS('\uf7d9'),
UNDO('\uf0e2'),
WALLET('\uf555');
@@ -0,0 +1,55 @@
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 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 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.";
}
}
@@ -0,0 +1,33 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class CoboVaultSinglesig extends ColdcardSinglesig {
@Override
public String getName() {
return "Cobo Vault";
}
@Override
public String getKeystoreImportDescription() {
return "Import file 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 {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Cobo Vault");
keystore.setWalletModel(getWalletModel());
return keystore;
}
}
@@ -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";
@@ -153,7 +150,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 +164,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");
@@ -5,9 +5,12 @@ 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.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.File;
@@ -16,7 +19,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";
@@ -77,6 +80,29 @@ 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));
if(!wallet.isValid()) {
throw new ImportException("Wallet is in an inconsistent state.");
}
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();
@@ -150,6 +184,20 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
throw new ImportException("Unknown Electrum wallet type of " + ew.wallet_type);
}
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(IllegalArgumentException e) {
//not a tx, probably an address
}
}
wallet.updateTransactions(ew.transactions);
if(!wallet.isValid()) {
throw new IllegalStateException("Electrum wallet is in an inconsistent state.");
}
@@ -279,6 +327,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 {
@@ -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,7 +24,6 @@ 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;
@@ -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));
@@ -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));
@@ -116,20 +116,51 @@ public class Hwi {
if(result.get("address") != null) {
return result.get("address").getAsString();
} else {
throw new DisplayAddressException("Could not retrieve address");
JsonElement error = result.get("error");
if(error != null) {
throw new DisplayAddressException(error.getAsString());
} else {
throw new DisplayAddressException("Could not retrieve address");
}
}
} catch(IOException e) {
throw new DisplayAddressException(e);
}
}
public String signMessage(Device device, String passphrase, String message, String derivationPath) throws SignMessageException {
try {
isPromptActive = false;
String output;
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));
}
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
if(result.get("signature") != null) {
return result.get("signature").getAsString();
} else {
JsonElement error = result.get("error");
if(error != null) {
throw new SignMessageException(error.getAsString());
} else {
throw new SignMessageException("Could not sign message");
}
}
} catch(IOException e) {
throw new SignMessageException(e);
}
}
public PSBT signPSBT(Device device, String passphrase, PSBT psbt) throws SignTransactionException {
try {
String psbtBase64 = psbt.toBase64String();
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));
@@ -169,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();
@@ -187,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.1.2-mac-amd64-signed.zip");
Path tempHwiDirPath = Files.createTempDirectory(TEMP_FILE_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
File tempHwiDir = tempHwiDirPath.toFile();
//tempHwiDir.deleteOnExit();
@@ -218,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));
}
@@ -292,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;
}
@@ -409,6 +450,30 @@ public class Hwi {
}
}
public static class SignMessageService extends Service<String> {
private final Device device;
private final String passphrase;
private final String message;
private final String derivationPath;
public SignMessageService(Device device, String passphrase, String message, String derivationPath) {
this.device = device;
this.passphrase = passphrase;
this.message = message;
this.derivationPath = derivationPath;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
protected String call() throws SignMessageException {
Hwi hwi = new Hwi();
return hwi.signMessage(device, passphrase, message, derivationPath);
}
};
}
}
public static class GetXpubService extends Service<String> {
private final Device device;
private final String passphrase;
@@ -479,6 +544,7 @@ public class Hwi {
PROMPT_PIN("promptpin", true),
SEND_PIN("sendpin", false),
DISPLAY_ADDRESS("displayaddress", true),
SIGN_MESSAGE("signmessage", true),
GET_XPUB("getxpub", true),
SIGN_TX("signtx", true);
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.io;
public class SignMessageException extends Exception {
public SignMessageException() {
super();
}
public SignMessageException(String message) {
super(message);
}
public SignMessageException(Throwable cause) {
super(cause);
}
public SignMessageException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,93 @@
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.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);
if(!wallet.isValid()) {
throw new ImportException("Specter wallet file did not contain a valid wallet");
}
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 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);
@@ -271,6 +278,18 @@ public class Storage {
}
static File getSparrowDir() {
if(Network.get() != Network.MAINNET) {
return new File(getSparrowHome(), Network.get().getName());
}
return getSparrowHome();
}
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 +465,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,21 +6,26 @@ 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 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();
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error pinging server", e);
}
@@ -30,7 +35,7 @@ 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();
return client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).param("client_name", clientName).param("protocol_version", supportedVersions).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error getting server version", e);
}
@@ -40,7 +45,7 @@ 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();
return client.createRequest().returnAs(String.class).method("server.banner").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error getting server banner", e);
}
@@ -50,7 +55,7 @@ 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();
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException e) {
throw new ElectrumServerRpcException("Error subscribing to block headers", e);
}
@@ -61,6 +66,7 @@ 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));
@@ -112,6 +118,7 @@ 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));
@@ -130,6 +137,8 @@ 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);
}
@@ -146,6 +155,8 @@ 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);
}
@@ -197,11 +208,21 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
}
@Override
public Double getMinimumRelayFee(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(Double.class).method("blockchain.relayfee").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException 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).param("raw_tx", txHex).execute();
return 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);
}
@@ -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,43 +135,52 @@ 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, nodes, 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);
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
//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);
historySize = purposeNode.getChildren().size();
gapLimitSize = getGapLimitSize(nodeTransactionMap);
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
}
//All WalletNode keys in nodeTransactionMap with non-null values need to have their history fetched
Collection<WalletNode> usedNodes = nodeTransactionMap.entrySet().stream().filter(entry -> entry.getValue() != null).map(Map.Entry::getKey).collect(Collectors.toList());
log.debug("Retrieving history for " + usedNodes.stream().map(WalletNode::getDerivationPath).collect(Collectors.joining(", ")));
getReferences(wallet, usedNodes, nodeTransactionMap);
//Set the remaining WalletNode keys to empty sets to indicate no history
nodeTransactionMap.entrySet().stream().filter(entry -> entry.getValue() == null).forEach(entry -> entry.setValue(Collections.emptySet()));
}
private int getGapLimitSize(Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
private int getGapLimitSize(Wallet wallet, 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;
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) throws ServerException {
try {
Map<String, String> pathScriptHashes = new LinkedHashMap<>(nodes.size());
for(WalletNode node : nodes) {
if(node.getIndex() >= startIndex) {
pathScriptHashes.put(node.getDerivationPath(), getScriptHash(wallet, node));
}
pathScriptHashes.put(node.getDerivationPath(), getScriptHash(wallet, node));
}
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.
@@ -207,14 +221,18 @@ 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) {
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 +250,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) {
@@ -501,7 +527,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 +537,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);
@@ -539,10 +576,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");
}
@@ -578,6 +629,7 @@ public class ElectrumServer {
private boolean firstCall = true;
private Thread reader;
private long feeRatesRetrievedAt;
private StringProperty statusProperty = new SimpleStringProperty();
public ConnectionService() {
this(true);
@@ -600,6 +652,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 +662,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 +677,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 +733,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,9 +759,16 @@ public class ElectrumServer {
public static class TransactionHistoryService extends Service<Boolean> {
private final Wallet wallet;
private final Set<WalletNode> nodes;
public TransactionHistoryService(Wallet wallet) {
this.wallet = wallet;
this.nodes = null;
}
public TransactionHistoryService(Wallet wallet, Set<WalletNode> nodes) {
this.wallet = wallet;
this.nodes = nodes;
}
@Override
@@ -702,7 +776,7 @@ public class ElectrumServer {
return new Task<>() {
protected Boolean call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = electrumServer.getHistory(wallet);
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;
@@ -7,22 +7,25 @@ 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 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();
client.createRequest().method("server.ping").id(idCounter.incrementAndGet()).executeNullable();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error pinging server", e);
}
@@ -33,7 +36,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
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();
return client.createRequest().returnAsList(String.class).method("server.version").id(idCounter.incrementAndGet()).params(clientName, "1.4").execute();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error getting server version", e);
}
@@ -43,7 +46,7 @@ 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();
return client.createRequest().returnAs(String.class).method("server.banner").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error getting server banner", e);
}
@@ -53,7 +56,7 @@ 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();
return client.createRequest().returnAs(BlockHeaderTip.class).method("blockchain.headers.subscribe").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException("Error subscribing to block headers", e);
}
@@ -65,8 +68,9 @@ 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 = 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) {
if(failOnError) {
@@ -87,7 +91,7 @@ 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 = 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) {
if(failOnError) {
@@ -107,9 +111,10 @@ 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, "");
String scriptHash = client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable();
result.put(path, scriptHash);
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException 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,8 +130,9 @@ 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 = 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() + ")");
@@ -144,8 +150,9 @@ 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 = client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute();
result.put(txid, rawTxHex);
} catch(JsonRpcException | IllegalStateException | IllegalArgumentException e) {
result.put(txid, Sha256Hash.ZERO_HASH.toString());
@@ -162,7 +169,7 @@ 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 = 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
@@ -171,13 +178,13 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
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,7 +216,7 @@ 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 = 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() + ")");
@@ -225,11 +232,21 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
return result;
}
@Override
public Double getMinimumRelayFee(Transport transport) {
try {
JsonRpcClient client = new JsonRpcClient(transport);
return client.createRequest().returnAs(Double.class).method("blockchain.relayfee").id(idCounter.incrementAndGet()).execute();
} catch(JsonRpcException 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();
return client.createRequest().returnAs(String.class).method("blockchain.transaction.broadcast").id(idCounter.incrementAndGet()).params(txHex).execute();
} catch(IllegalStateException | IllegalArgumentException e) {
throw new ElectrumServerRpcException(e.getMessage(), e);
} catch(JsonRpcException 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)));
@@ -11,6 +11,8 @@ import org.slf4j.LoggerFactory;
import javax.net.SocketFactory;
import java.io.*;
import java.net.Socket;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class TcpTransport implements Transport, Closeable {
@@ -25,6 +27,9 @@ 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;
@@ -57,64 +62,83 @@ 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(60, TimeUnit.SECONDS)) {
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);
try {
if(firstRead) {
readingCondition.signal();
firstRead = false;
}
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();
}
reading = true;
notifyAll();
return response;
}
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")) {
//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();
}
}
@@ -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;
}
}
}
@@ -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) {
@@ -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;
@@ -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;
@@ -49,7 +49,7 @@ 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 +121,7 @@ public class HeadersController extends TransactionFormController implements Init
private CopyableLabel feeRate;
@FXML
private Form blockchainForm;
private DynamicForm blockchainForm;
@FXML
private Label blockStatus;
@@ -410,6 +410,8 @@ public class HeadersController extends TransactionFormController implements Init
EventManager.get().post(new RequestOpenWalletsEvent());
}
blockchainForm.setDynamicUpdate(this);
}
private void updateType() {
@@ -493,6 +495,8 @@ public class HeadersController extends TransactionFormController implements Init
indicator = (ConfirmationProgressIndicator)blockStatus.getGraphic();
indicator.setConfirmations(confirmations);
}
} else {
blockStatus.setGraphic(null);
}
}
@@ -779,6 +783,19 @@ public class HeadersController extends TransactionFormController implements Init
}
}
@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())) {
@@ -14,7 +14,7 @@ import java.util.zip.CRC32;
* Ported from https://github.com/BlockchainCommons/URKit
*/
public class Bytewords {
public static final String BYTEWORDS = "ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbrewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadaysdelidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfactfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgiftgirlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhutsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjurykeepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlistlimplionlogoloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneednewsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolposepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesagascarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoiltombtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswallwandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszestzinczonezoomzero";
public static final String BYTEWORDS = "ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbrewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadaysdelidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfactfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgiftgirlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhutsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjurykeepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlogoloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneednewsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolposepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesagascarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoiltombtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswallwandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzinczonezoom";
private static final List<String> bytewordsList;
private static final List<String> minimalBytewordsList;
@@ -81,6 +81,10 @@ public class UR {
}
}
public String toString() {
return UREncoder.encode(this);
}
@Override
public boolean equals(Object o) {
if(this == o) {
@@ -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);
}
}
}
@@ -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,55 @@ 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.error != null) {
AppController.showErrorDialog("Invalid QR Code", result.error);
} else if(result.exception != null) {
log.error("Error opening webcam", result.exception);
AppController.showErrorDialog("Error opening webcam", 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());
}
}
}
@@ -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,6 +16,7 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -32,14 +33,19 @@ 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.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;
@@ -93,8 +99,12 @@ public class SendController extends WalletFormController implements Initializabl
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<>() {
@@ -152,6 +162,8 @@ public class SendController extends WalletFormController implements Initializabl
}
};
private ValidationSupport validationSupport;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -163,10 +175,14 @@ public class SendController extends WalletFormController implements Initializabl
revalidate(amount, amountListener);
maxButton.setDisable(!isValidRecipientAddress());
updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
});
label.textProperty().addListener((observable, oldValue, newValue) -> {
createButton.setDisable(walletTransactionProperty.get() == null || newValue == null || newValue.isEmpty());
createButton.setDisable(walletTransactionProperty.get() == null || newValue == null || newValue.isEmpty() || isInsufficientFeeRate());
});
amount.setTextFormatter(new CoinTextFormatter());
@@ -250,16 +266,11 @@ public class SendController extends WalletFormController implements Initializabl
});
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) -> {
@@ -269,7 +280,7 @@ public class SendController extends WalletFormController implements Initializabl
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), walletTransaction.getRecipientAmount());
}
double feeRate = (double)walletTransaction.getFee() / walletTransaction.getTransaction().getVirtualSize();
double feeRate = walletTransaction.getFeeRate();
if(userFeeSet.get()) {
setTargetBlocks(getTargetBlocks(feeRate));
} else {
@@ -280,14 +291,14 @@ public class SendController extends WalletFormController implements Initializabl
}
transactionDiagram.update(walletTransaction);
createButton.setDisable(walletTransaction == null || label.getText().isEmpty());
createButton.setDisable(walletTransaction == null || label.getText().isEmpty() || isInsufficientFeeRate());
});
addValidation();
}
private void addValidation() {
ValidationSupport validationSupport = new ValidationSupport();
validationSupport = new ValidationSupport();
validationSupport.registerValidator(address, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress())
));
@@ -300,7 +311,8 @@ public class SendController extends WalletFormController implements Initializabl
));
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());
@@ -321,7 +333,7 @@ public class SendController extends WalletFormController implements Initializabl
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(), recipientAddress, recipientAmount, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, sendAll, groupByAddress, includeMempoolChange);
walletTransactionProperty.setValue(walletTransaction);
insufficientInputsProperty.set(false);
@@ -348,6 +360,15 @@ public class SendController extends WalletFormController implements Initializabl
return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee));
}
private List<UtxoFilter> getUtxoFilters() {
UtxoFilter utxoFilter = utxoFilterProperty.get();
if(utxoFilter != null) {
return List.of(utxoFilter);
}
return Collections.emptyList();
}
private boolean isValidRecipientAddress() {
try {
getRecipientAddress();
@@ -439,7 +460,7 @@ public class SendController extends WalletFormController implements Initializabl
LinkedHashMap::new));
}
return retrievedFeeRates;
return retrievedFeeRates;
}
private Double getFeeRate() {
@@ -452,6 +473,10 @@ public class SendController extends WalletFormController implements Initializabl
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
private 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,10 +485,6 @@ 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) {
@@ -498,6 +519,25 @@ public class SendController extends WalletFormController implements Initializabl
return address.getScriptType().getDustThreshold(txOutput, getFeeRate());
}
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)";
maxButton.setText("Max" + selection);
clearButton.setText("Clear" + selection);
} else if(utxoFilter instanceof ExcludeUtxoFilter) {
ExcludeUtxoFilter excludeUtxoFilter = (ExcludeUtxoFilter)utxoFilter;
int num = excludeUtxoFilter.getExcludedUtxos().size();
String exclusion = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " excluded)";
maxButton.setText("Max" + exclusion);
clearButton.setText("Clear" + exclusion);
} else {
maxButton.setText("Max");
clearButton.setText("Clear");
}
}
public void scanQrAddress(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
@@ -536,7 +576,11 @@ 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);
}
private void revalidate(TextField field, ChangeListener<String> listener) {
@@ -548,10 +592,34 @@ 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));
}
private void addWalletTransactionNodes() {
WalletTransaction walletTransaction = walletTransactionProperty.get();
Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
nodes.add(walletTransaction.getChangeNode());
WalletNode consolidationNode = walletTransaction.getConsolidationSendNode();
if(consolidationNode != null) {
nodes.add(consolidationNode);
}
//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
public void walletNodesChanged(WalletNodesChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
@@ -561,11 +629,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,7 +676,8 @@ 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);
}
}
@@ -612,4 +705,37 @@ public class SendController extends WalletFormController implements Initializabl
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;
@@ -13,13 +14,17 @@ 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.DescriptorArea;
import com.sparrowwallet.sparrow.control.TextAreaDialog;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
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 +51,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 +91,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 +165,8 @@ public class SettingsController extends WalletFormController implements Initiali
}
});
initializeDescriptorField(descriptor);
revert.setOnAction(event -> {
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
totalKeystores.unbind();
@@ -172,7 +179,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 +231,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 +251,52 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
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)) {
try {
OutputDescriptor editedOutputDescriptor = OutputDescriptor.getOutputDescriptor(text.get());
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 +307,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 +348,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 +374,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());
@@ -25,6 +25,11 @@ public class SettingsWalletForm extends WalletForm {
return walletCopy;
}
@Override
public void setWallet(Wallet wallet) {
this.walletCopy = wallet;
}
@Override
public void revert() {
this.walletCopy = super.getWallet().copy();
@@ -77,6 +82,10 @@ public class SettingsWalletForm extends WalletForm {
}
}
if(original.getGapLimit() != changed.getGapLimit()) {
return true;
}
return false;
}
}
@@ -116,13 +116,14 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TransactionEntry that = (TransactionEntry) o;
return wallet.equals(that.wallet) &&
blockTransaction.equals(that.blockTransaction);
// Even though the txid identifies a transaction, receiving an incomplete set of script hash notifications can result in some inputs/outputs for a tx being missing.
// To resolve this we check the number of children, but not the children themselves (since we don't care here when they are spent)
return wallet.equals(that.wallet) && blockTransaction.equals(that.blockTransaction) && getChildren().size() == that.getChildren().size();
}
@Override
public int hashCode() {
return Objects.hash(wallet, blockTransaction);
return Objects.hash(wallet, blockTransaction, getChildren().size());
}
@Override
@@ -122,4 +122,9 @@ public class TransactionsController extends WalletFormController implements Init
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatBalance(event.getCurrencyRate(), getWalletForm().getWalletTransactionsEntry().getBalance());
}
@Subscribe
public void walletHistoryStatus(WalletHistoryStatusEvent event) {
transactionsTable.updateHistoryStatus(event);
}
}
@@ -125,4 +125,9 @@ public class UtxosController extends WalletFormController implements Initializab
utxosChart.setBitcoinUnit(getWalletForm().getWallet(), event.getBitcoinUnit());
updateSendSelected(event.getBitcoinUnit());
}
@Subscribe
public void walletHistoryStatus(WalletHistoryStatusEvent event) {
utxosTable.updateHistoryStatus(event);
}
}
@@ -27,6 +27,7 @@ public class WalletForm {
private WalletTransactionsEntry walletTransactionsEntry;
private WalletUtxosEntry walletUtxosEntry;
private final List<NodeEntry> accountEntries = new ArrayList<>();
private final List<Set<WalletNode>> walletTransactionNodes = new ArrayList<>();
public WalletForm(Storage storage, Wallet currentWallet) {
this.storage = storage;
@@ -46,6 +47,10 @@ public class WalletForm {
return storage.getWalletFile();
}
public void setWallet(Wallet wallet) {
throw new UnsupportedOperationException("Only SettingsWalletForm supports setWallet");
}
public void revert() {
throw new UnsupportedOperationException("Only SettingsWalletForm supports revert");
}
@@ -65,15 +70,23 @@ public class WalletForm {
}
public void refreshHistory(Integer blockHeight) {
refreshHistory(blockHeight, null);
}
public void refreshHistory(Integer blockHeight, WalletNode node) {
Wallet previousWallet = wallet.copy();
if(wallet.isValid() && AppController.isOnline()) {
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet);
log.debug(node == null ? "Refreshing full wallet history" : "Requesting node wallet history for " + node.getDerivationPath());
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(node));
historyService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new WalletHistoryStatusEvent(true));
updateWallet(previousWallet, blockHeight);
});
historyService.setOnFailed(workerStateEvent -> {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
EventManager.get().post(new WalletHistoryStatusEvent(workerStateEvent.getSource().getException().getMessage()));
});
EventManager.get().post(new WalletHistoryStatusEvent(false));
historyService.start();
}
}
@@ -124,6 +137,25 @@ public class WalletForm {
return changedNodes;
}
public void addWalletTransactionNodes(Set<WalletNode> transactionNodes) {
walletTransactionNodes.add(transactionNodes);
}
private Set<WalletNode> getWalletTransactionNodes(WalletNode walletNode) {
if(walletNode == null) {
return null;
}
Set<WalletNode> allNodes = new LinkedHashSet<>();
for(Set<WalletNode> nodes : walletTransactionNodes) {
if(nodes.contains(walletNode)) {
allNodes.addAll(nodes);
}
}
return allNodes.isEmpty() ? Set.of(walletNode) : allNodes;
}
public NodeEntry getNodeEntry(KeyPurpose keyPurpose) {
NodeEntry purposeEntry;
Optional<NodeEntry> optionalPurposeEntry = accountEntries.stream().filter(entry -> entry.getNode().getKeyPurpose().equals(keyPurpose)).findFirst();
@@ -213,8 +245,12 @@ public class WalletForm {
@Subscribe
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
if(event.getWalletNode(wallet) != null) {
refreshHistory(AppController.getCurrentBlockHeight());
if(wallet.isValid()) {
WalletNode walletNode = event.getWalletNode(wallet);
if(walletNode != null) {
log.debug(wallet.getName() + " history event for node " + walletNode);
refreshHistory(AppController.getCurrentBlockHeight(), walletNode);
}
}
}
@@ -89,21 +89,26 @@ public class WalletTransactionsEntry extends Entry {
for(WalletNode addressNode : purposeNode.getChildren()) {
for(BlockTransactionHashIndex hashIndex : addressNode.getTransactionOutputs()) {
BlockTransaction inputTx = wallet.getTransactions().get(hashIndex.getHash());
WalletTransaction inputWalletTx = walletTransactionMap.get(inputTx);
if(inputWalletTx == null) {
inputWalletTx = new WalletTransaction(wallet, inputTx);
walletTransactionMap.put(inputTx, inputWalletTx);
}
inputWalletTx.incoming.put(hashIndex, keyPurpose);
if(hashIndex.getSpentBy() != null) {
BlockTransaction outputTx = wallet.getTransactions().get(hashIndex.getSpentBy().getHash());
WalletTransaction outputWalletTx = walletTransactionMap.get(outputTx);
if(outputWalletTx == null) {
outputWalletTx = new WalletTransaction(wallet, outputTx);
walletTransactionMap.put(outputTx, outputWalletTx);
//A null inputTx here means the wallet is still updating - ignore as the WalletHistoryChangedEvent will run this again
if(inputTx != null) {
WalletTransaction inputWalletTx = walletTransactionMap.get(inputTx);
if(inputWalletTx == null) {
inputWalletTx = new WalletTransaction(wallet, inputTx);
walletTransactionMap.put(inputTx, inputWalletTx);
}
inputWalletTx.incoming.put(hashIndex, keyPurpose);
if(hashIndex.getSpentBy() != null) {
BlockTransaction outputTx = wallet.getTransactions().get(hashIndex.getSpentBy().getHash());
if(outputTx != null) {
WalletTransaction outputWalletTx = walletTransactionMap.get(outputTx);
if(outputWalletTx == null) {
outputWalletTx = new WalletTransaction(wallet, outputTx);
walletTransactionMap.put(outputTx, outputWalletTx);
}
outputWalletTx.outgoing.put(hashIndex.getSpentBy(), keyPurpose);
}
}
outputWalletTx.outgoing.put(hashIndex.getSpentBy(), keyPurpose);
}
}
}
+2
View File
@@ -20,6 +20,8 @@ open module com.sparrowwallet.sparrow {
requires com.fasterxml.jackson.databind;
requires cbor;
requires webcam.capture;
requires netlayer.jpms;
requires centerdevice.nsmenufx;
requires jcommander;
requires slf4j.api;
}
@@ -7,6 +7,7 @@
<?import javafx.scene.shape.Rectangle?>
<?import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch?>
<?import com.sparrowwallet.drongo.BitcoinUnit?>
<?import com.sparrowwallet.sparrow.Theme?>
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1000.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<children>
@@ -29,16 +30,19 @@
<SeparatorMenuItem />
<MenuItem mnemonicParsing="false" text="Import Wallet..." onAction="#importWallet"/>
<MenuItem fx:id="exportWallet" mnemonicParsing="false" text="Export Wallet..." onAction="#exportWallet"/>
<SeparatorMenuItem styleClass="macHide" />
<MenuItem styleClass="macHide" mnemonicParsing="false" text="Preferences..." onAction="#openPreferences"/>
<SeparatorMenuItem styleClass="osxHide" />
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="Preferences..." onAction="#openPreferences"/>
<SeparatorMenuItem />
<MenuItem mnemonicParsing="false" text="Close Tab" onAction="#closeTab"/>
<MenuItem styleClass="macHide" mnemonicParsing="false" text="Quit" onAction="#quit"/>
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="Quit" onAction="#quit"/>
</items>
</Menu>
<fx:define>
<ToggleGroup fx:id="bitcoinUnit"/>
</fx:define>
<fx:define>
<ToggleGroup fx:id="theme"/>
</fx:define>
<Menu mnemonicParsing="false" text="View">
<items>
<Menu mnemonicParsing="false" text="Bitcoin Unit">
@@ -60,11 +64,28 @@
</RadioMenuItem>
</items>
</Menu>
<Menu mnemonicParsing="false" text="Theme">
<items>
<RadioMenuItem mnemonicParsing="false" text="Light" toggleGroup="$theme" onAction="#setTheme">
<userData>
<Theme fx:constant="LIGHT" />
</userData>
</RadioMenuItem>
<RadioMenuItem mnemonicParsing="false" text="Dark" toggleGroup="$theme" onAction="#setTheme">
<userData>
<Theme fx:constant="DARK" />
</userData>
</RadioMenuItem>
</items>
</Menu>
<CheckMenuItem fx:id="showTxHex" mnemonicParsing="false" text="Show Transaction Hex" onAction="#showTxHex"/>
</items>
</Menu>
<Menu fx:id="toolsMenu" mnemonicParsing="false" text="Tools">
<MenuItem mnemonicParsing="false" text="Sign/Verify Message" onAction="#signVerifyMessage"/>
</Menu>
<Menu fx:id="helpMenu" mnemonicParsing="false" text="Help">
<MenuItem styleClass="macHide" mnemonicParsing="false" text="About Sparrow" onAction="#showAbout"/>
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="About Sparrow" onAction="#showAbout"/>
</Menu>
</menus>
</MenuBar>
@@ -0,0 +1,155 @@
.root {
-fx-accent: #1e88cf;
-fx-focus-color: -fx-accent;
-fx-base: #373e43;
-fx-control-inner-background: derive(-fx-base, 35%);
-fx-control-inner-background-alt: -fx-control-inner-background ;
}
.label{
-fx-text-fill: lightgray;
}
.text-field {
-fx-prompt-text-fill: gray;
}
.titulo{
-fx-font-weight: bold;
-fx-font-size: 18px;
}
.button{
-fx-focus-traversable: false;
}
.button:hover{
-fx-text-fill: white;
}
.separator *.line {
-fx-background-color: #3C3C3C;
-fx-border-style: solid;
-fx-border-width: 1px;
}
.scroll-bar{
-fx-background-color: derive(-fx-base,45%)
}
.button:default {
-fx-base: -fx-accent ;
}
.table-view{
/*-fx-background-color: derive(-fx-base, 10%);*/
-fx-selection-bar-non-focused: derive(-fx-base, 50%);
}
.table-view .column-header .label{
-fx-alignment: CENTER_LEFT;
-fx-font-weight: none;
}
.list-cell:even,
.list-cell:odd,
.table-row-cell:even,
.table-row-cell:odd{
-fx-control-inner-background: derive(-fx-base, 15%);
}
.list-cell:empty,
.table-row-cell:empty {
-fx-background-color: transparent;
}
.list-cell,
.table-row-cell{
-fx-border-color: transparent;
-fx-table-cell-border-color:transparent;
}
.status-bar {
-fx-background-color: black, -fx-body-color;
}
.chart .default-color0.chart-series-line {
-fx-stroke: rgba(135, 138, 149, 0.6);
}
.chart .chart-bar {
-fx-bar-fill: rgba(135, 138, 149, 0.5);
}
.root .titled-description-pane .status-error .text, .root .titled-description-pane .description-error .text {
-fx-fill: #e06c75;
}
#inputsPie .default-color0.chart-pie {
-fx-pie-color: #e06c75;
}
#outputsPie .default-color3.chart-pie {
-fx-pie-color: #e06c75;
}
.root .descriptor-error {
-fx-fill: #e06c75;
}
.root .etched-raised-border {
-fx-border-color: #ffffff, #000000;
-fx-border-style: solid, solid;
-fx-border-width: 1px, 1px;
}
.root .line-border {
-fx-border-color: #000000;
-fx-border-style: solid;
-fx-border-width: 1px;
}
.root .duplicate-warning, .root #transactionDiagram .fee-warning-icon {
-fx-text-fill: #e06c75;
}
.root .unused-check {
-fx-text-fill: #98c379;
}
.root .script-nest { -fx-fill: #565c64 }
.root .script-opcode { -fx-fill: #56b6c2 }
.root .script-hash { -fx-fill: #d19a66 }
.root .script-signature { -fx-fill: #98c379 }
.root .script-pubkey { -fx-fill: #c678dd }
.root .script-redeem { -fx-fill: derive(#e06c75, 20%) }
.root .script-other { -fx-fill: #c8ccd4 }
.root #txhex {
-fx-background-color: derive(-fx-control-inner-background, -50%);
color-0: #e06c75;
color-1: #e5c07b;
color-2: #d19a66;
color-3: #98c379;
color-4: #61afef;
color-5: #56b6c2;
color-6: #c678dd;
color-7: #be5046;
color-8: #c8ccd4;
color-grey: #3e4451;
}
.root .success {
-fx-text-fill: #98c379;
}
.root .failure {
-fx-text-fill: #e06c75;
}
.root .titled-description-pane > .title {
-fx-background-color: derive(-fx-base, 10%);
-fx-padding: 0;
-fx-border-color: derive(-fx-base, -2%);
/*-fx-border-width: 1;*/
}
@@ -0,0 +1,3 @@
.descriptor-text { -fx-fill: -fx-text-inner-color }
.descriptor-error { -fx-fill: #ca1243 }
@@ -27,7 +27,7 @@
-fx-padding: 3 5;
}
.id {
.id, .fixed-width {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
}
@@ -126,4 +126,28 @@
.default-button {
-fx-base: -fx-default-button;
}
}
.etched-raised-border {
-fx-border-color: #ffffff, #a9a9a9;
-fx-border-style: solid, solid;
-fx-border-width: 1px, 1px;
}
.line-border {
-fx-border-color: #a9a9a9;
-fx-border-style: solid;
-fx-border-width: 1px;
}
.success {
-fx-text-fill: rgb(80, 161, 79);
}
.failure {
-fx-text-fill: rgb(202, 18, 67);
}
.root .header-panel {
-fx-background-color: -fx-box-border, derive(-fx-background, 10%);
}
@@ -9,7 +9,7 @@
<?import com.sparrowwallet.drongo.wallet.KeystoreSource?>
<?import javafx.geometry.Insets?>
<BorderPane stylesheets="@../general.css, @keystoreimport.css" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.sparrowwallet.sparrow.keystoreimport.KeystoreImportController">
<BorderPane stylesheets="@../general.css, @keystoreimport.css" styleClass="line-border" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.sparrowwallet.sparrow.keystoreimport.KeystoreImportController">
<padding>
<Insets top="0" left="0" right="0" bottom="0" />
</padding>
@@ -9,14 +9,14 @@
.notification-bar > .pane .label.title {
-fx-font-size: 15px;
-fx-text-fill: #292929;
-fx-text-fill: -fx-text-inner-color;
-fx-padding: 5 0 0 68;
-fx-translate-y: 10;
}
.notification-bar > .pane .label {
-fx-font-size: 1em;
-fx-text-fill: #292929;
-fx-text-fill: -fx-text-inner-color;
-fx-alignment: top-left;
-fx-padding: 0 0 0 10;
-fx-graphic-text-gap: 8;
@@ -39,7 +39,7 @@
</FXCollections>
</items>
</ComboBox>
<HelpLabel helpText="Display unit for bitcoin amounts. Auto displays amounts over 1 BTC in BTC, and amounts under in satoshis"/>
<HelpLabel helpText="Display unit for bitcoin amounts.\nAuto displays amounts over 1 BTC in BTC, and amounts under that in satoshis."/>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Fiat" styleClass="wideLabelFieldSet">
@@ -61,17 +61,21 @@
<Fieldset inputGrow="SOMETIMES" text="Coin Selection" styleClass="wideLabelFieldSet">
<Field text="Group by address:">
<UnlabeledToggleSwitch fx:id="groupByAddress" />
<HelpLabel helpText="Group UTXOs by address when sending to improve privacy by only sending from an address once"/>
<HelpLabel helpText="Group UTXOs by address when sending to improve privacy by only sending from an address once."/>
</Field>
<Field text="Use mempool change:">
<UnlabeledToggleSwitch fx:id="includeMempoolChange" />
<HelpLabel helpText="Allow a wallet to spend UTXOs that are still in the mempool where all their inputs are from that wallet"/>
<HelpLabel helpText="Allow a wallet to spend UTXOs that are still in the mempool where all their inputs are from that wallet."/>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Notifications" styleClass="wideLabelFieldSet">
<Field text="New transactions:">
<UnlabeledToggleSwitch fx:id="notifyNewTransactions" />
<HelpLabel helpText="Show system notifications on new wallet transactions"/>
<HelpLabel helpText="Show system notifications on new wallet transactions?"/>
</Field>
<Field text="Software updates:">
<UnlabeledToggleSwitch fx:id="checkNewVersions" />
<HelpLabel helpText="Check for updates to Sparrow?"/>
</Field>
</Fieldset>
</Form>
@@ -9,7 +9,7 @@
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.preferences.PreferenceGroup?>
<?import org.controlsfx.glyphfont.Glyph?>
<BorderPane stylesheets="@../general.css, @preferences.css" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.sparrowwallet.sparrow.preferences.PreferencesController">
<BorderPane stylesheets="@../general.css, @preferences.css" styleClass="line-border" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" fx:controller="com.sparrowwallet.sparrow.preferences.PreferencesController">
<left>
<VBox styleClass="list-menu">
<ToggleButton VBox.vgrow="ALWAYS" text="General" wrapText="true" textAlignment="CENTER" contentDisplay="TOP" styleClass="list-item" maxHeight="Infinity" toggleGroup="$preferencesMenu">
@@ -31,6 +31,7 @@
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
-fx-padding: 4;
-fx-fill: -fx-text-inner-color;
}
.virtualized-scroll-pane {
@@ -1,24 +1,24 @@
.version { -fx-fill: #986801 }
.segwit-marker { -fx-fill: #000000 }
.segwit-flag { -fx-fill: #4078f2 }
.version { -fx-fill: color-7 }
.segwit-marker { -fx-fill: color-8 }
.segwit-flag { -fx-fill: color-5 }
.num-inputs { -fx-fill: #ca1243 }
.input-hash { -fx-fill: #0184bc }
.input-index { -fx-fill: #0184bc }
.input-sigscript-length { -fx-fill: #0184bc }
.input-sigscript { -fx-fill: #0184bc }
.input-sequence { -fx-fill: #0184bc }
.num-inputs { -fx-fill: color-0 }
.input-hash { -fx-fill: color-4 }
.input-index { -fx-fill: color-4 }
.input-sigscript-length { -fx-fill: color-4 }
.input-sigscript { -fx-fill: color-4 }
.input-sequence { -fx-fill: color-4 }
.num-outputs { -fx-fill: #ca1243 }
.output-value { -fx-fill: #50a14f }
.output-pubkeyscript-length { -fx-fill: #50a14f }
.output-pubkeyscript { -fx-fill: #50a14f }
.num-outputs { -fx-fill: color-0 }
.output-value { -fx-fill: color-3 }
.output-pubkeyscript-length { -fx-fill: color-3 }
.output-pubkeyscript { -fx-fill: color-3 }
.witness-count { -fx-fill: #ca1243 }
.witness-length { -fx-fill: #a626a4 }
.witness-data { -fx-fill: #a626a4 }
.witness-count { -fx-fill: color-0 }
.witness-length { -fx-fill: color-6 }
.witness-data { -fx-fill: color-6 }
.locktime { -fx-fill: #986801 }
.locktime { -fx-fill: color-7 }
#locktimeCurrentHeight {
-fx-padding: 0 0 0 12;
@@ -24,6 +24,7 @@
<?import com.sparrowwallet.sparrow.control.SignaturesProgressBar?>
<?import javafx.scene.control.ProgressBar?>
<?import javafx.scene.control.Tooltip?>
<?import com.sparrowwallet.sparrow.control.DynamicForm?>
<GridPane hgap="10.0" vgap="10.0" styleClass="tx-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.transaction.HeadersController" stylesheets="@headers.css, @transaction.css, @../general.css">
<padding>
@@ -131,7 +132,7 @@
<Separator GridPane.columnIndex="0" GridPane.rowIndex="5" GridPane.columnSpan="2" styleClass="form-separator"/>
<Form fx:id="blockchainForm" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="6">
<DynamicForm fx:id="blockchainForm" GridPane.columnIndex="0" GridPane.columnSpan="2" GridPane.rowIndex="6">
<Fieldset text="Blockchain" inputGrow="SOMETIMES">
<Field text="Status:">
<Label fx:id="blockStatus" contentDisplay="RIGHT" graphicTextGap="5" />
@@ -146,7 +147,7 @@
<IdLabel fx:id="blockHash" />
</Field>
</Fieldset>
</Form>
</DynamicForm>
<Form fx:id="signingWalletForm" GridPane.columnIndex="0" GridPane.rowIndex="6">
<Fieldset text="Signatures" inputGrow="SOMETIMES">
@@ -1,27 +1,27 @@
.version { -fx-fill: #e5e5e6 }
.segwit-marker { -fx-fill: #e5e5e6 }
.segwit-flag { -fx-fill: #e5e5e6 }
.version { -fx-fill: color-grey }
.segwit-marker { -fx-fill: color-grey }
.segwit-flag { -fx-fill: color-grey }
.num-inputs { -fx-fill: #e5e5e6 }
.input-other { -fx-fill: #e5e5e6 }
.num-inputs { -fx-fill: color-grey }
.input-other { -fx-fill: color-grey }
.input-hash { -fx-fill: #0184bc }
.input-index { -fx-fill: #000000 }
.input-sigscript-length { -fx-fill: #a626a4 }
.input-sigscript { -fx-fill: #50a14f }
.input-sequence { -fx-fill: #986801 }
.input-hash { -fx-fill: color-4 }
.input-index { -fx-fill: color-8 }
.input-sigscript-length { -fx-fill: color-6 }
.input-sigscript { -fx-fill: color-3 }
.input-sequence { -fx-fill: color-7 }
.num-outputs { -fx-fill: #e5e5e6 }
.output-value { -fx-fill: #e5e5e6 }
.output-pubkeyscript-length { -fx-fill: #e5e5e6 }
.output-pubkeyscript { -fx-fill: #e5e5e6 }
.num-outputs { -fx-fill: color-grey }
.output-value { -fx-fill: color-grey }
.output-pubkeyscript-length { -fx-fill: color-grey }
.output-pubkeyscript { -fx-fill: color-grey }
.witness-other { -fx-fill: #e5e5e6 }
.witness-other { -fx-fill: color-grey }
.witness-count { -fx-fill: #ca1243 }
.witness-length { -fx-fill: #986801 }
.witness-data { -fx-fill: #a626a4 }
.witness-count { -fx-fill: color-0 }
.witness-length { -fx-fill: color-7 }
.witness-data { -fx-fill: color-6 }
.locktime { -fx-fill: #e5e5e6 }
.locktime { -fx-fill: color-grey }
@@ -1,24 +1,24 @@
.version { -fx-fill: #e5e5e6 }
.segwit-marker { -fx-fill: #e5e5e6 }
.segwit-flag { -fx-fill: #e5e5e6 }
.version { -fx-fill: color-grey }
.segwit-marker { -fx-fill: color-grey }
.segwit-flag { -fx-fill: color-grey }
.num-inputs { -fx-fill: #ca1243 }
.input-hash { -fx-fill: #0184bc }
.input-index { -fx-fill: #000000 }
.input-sigscript-length { -fx-fill: #a626a4 }
.input-sigscript { -fx-fill: #50a14f }
.input-sequence { -fx-fill: #986801 }
.num-inputs { -fx-fill: color-0 }
.input-hash { -fx-fill: color-4 }
.input-index { -fx-fill: color-8 }
.input-sigscript-length { -fx-fill: color-6 }
.input-sigscript { -fx-fill: color-3 }
.input-sequence { -fx-fill: color-7 }
.num-outputs { -fx-fill: #e5e5e6 }
.output-value { -fx-fill: #e5e5e6 }
.output-pubkeyscript-length { -fx-fill: #e5e5e6 }
.output-pubkeyscript { -fx-fill: #e5e5e6 }
.num-outputs { -fx-fill: color-grey }
.output-value { -fx-fill: color-grey }
.output-pubkeyscript-length { -fx-fill: color-grey }
.output-pubkeyscript { -fx-fill: color-grey }
.witness-count { -fx-fill: #ca1243 }
.witness-length { -fx-fill: #986801 }
.witness-data { -fx-fill: #a626a4 }
.witness-count { -fx-fill: color-0 }
.witness-length { -fx-fill: color-7 }
.witness-data { -fx-fill: color-6 }
.locktime { -fx-fill: #e5e5e6 }
.locktime { -fx-fill: color-grey }
.chart-legend-item {
-fx-font-size: 13px;
@@ -1,26 +1,26 @@
.version { -fx-fill: #e5e5e6 }
.segwit-marker { -fx-fill: #e5e5e6 }
.segwit-flag { -fx-fill: #e5e5e6 }
.version { -fx-fill: color-grey }
.segwit-marker { -fx-fill: color-grey }
.segwit-flag { -fx-fill: color-grey }
.num-inputs { -fx-fill: #e5e5e6 }
.input-hash { -fx-fill: #e5e5e6 }
.input-index { -fx-fill: #e5e5e6 }
.input-sigscript-length { -fx-fill: #e5e5e6 }
.input-sigscript { -fx-fill: #e5e5e6 }
.input-sequence { -fx-fill: #e5e5e6 }
.num-inputs { -fx-fill: color-grey }
.input-hash { -fx-fill: color-grey }
.input-index { -fx-fill: color-grey }
.input-sigscript-length { -fx-fill: color-grey }
.input-sigscript { -fx-fill: color-grey }
.input-sequence { -fx-fill: color-grey }
.num-outputs { -fx-fill: #e5e5e6 }
.output-other { -fx-fill: #e5e5e6 }
.num-outputs { -fx-fill: color-grey }
.output-other { -fx-fill: color-grey }
.output-value { -fx-fill: #000000 }
.output-pubkeyscript-length { -fx-fill: #a626a4 }
.output-pubkeyscript { -fx-fill: #50a14f }
.output-value { -fx-fill: color-8 }
.output-pubkeyscript-length { -fx-fill: color-6 }
.output-pubkeyscript { -fx-fill: color-3 }
.witness-count { -fx-fill: #e5e5e6 }
.witness-length { -fx-fill: #e5e5e6 }
.witness-data { -fx-fill: #e5e5e6 }
.witness-count { -fx-fill: color-grey }
.witness-length { -fx-fill: color-grey }
.witness-data { -fx-fill: color-grey }
.locktime { -fx-fill: #e5e5e6 }
.locktime { -fx-fill: color-grey }
#spentField .input-container {
-fx-alignment: center-left;

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