Compare commits

...
69 Commits
Author SHA1 Message Date
Craig Raw 685fef6b76 shorten labels for linux 2021-04-08 09:20:35 +02:00
Craig Raw 9a4eb22396 v1.3.2 2021-04-08 08:38:16 +02:00
Craig Raw 57eb3a77e7 reduce error logging when fee and exchanges rate sources are unavailable 2021-04-08 08:34:31 +02:00
Craig Raw dcc63046bd switch to secure openimaj repo 2021-04-07 15:56:10 +02:00
Craig Raw 8388a7fed5 reduce camera cpu usage through sleeping webcam capture threads to match 10fps 2021-04-07 13:34:06 +02:00
Craig Raw d5830399b7 upgrade to hwi 2.0.1 2021-04-07 11:50:52 +02:00
Craig Raw 38b8fa3b86 add view menu item to switch hd capture 2021-04-07 10:36:18 +02:00
Craig Raw 6f26c7e138 update passport import descriptions 2021-04-07 10:01:44 +02:00
Craig Raw 9e869ae69b handle no camera detected better 2021-04-07 09:56:51 +02:00
Craig Raw 4a7b0b20ad tray changes for windows 2021-04-07 09:45:52 +02:00
Craig Raw 8fc971c07c add minimize to system tray functionality 2021-04-07 09:32:01 +02:00
Craig Raw cb884d97cb request strongest instance of securerandom when generating a seed 2021-04-06 11:46:51 +02:00
Craig Raw 942db9df48 edit passport wallet export description 2021-04-06 09:34:05 +02:00
Craig Raw 193f88b88f implement changes to export addresses functionality 2021-04-06 09:27:51 +02:00
craigrawandGitHub 6785bccf0e Merge pull request #89 from jimbojw/export-addresses
Export Addresses to CSV File
2021-04-06 08:40:43 +02:00
Craig Raw 579b9a685b automatically switch between internal and external tor proxy as required when connecting to server 2021-04-05 14:56:23 +02:00
Jimbo 57b3fd99bc Correcting address column in exported CSV, removing Derivation column. 2021-04-05 07:00:04 -04:00
Craig Raw c3ae98f3d1 add passport multisig wallet export 2021-04-05 12:19:18 +02:00
Craig Raw d635815607 reduce java-based cpu usage when scanning 2021-04-05 11:29:13 +02:00
Craig Raw e524396aaf add trezor one passphrase toggle capability 2021-04-02 15:26:43 +02:00
Craig Raw 08edc04c6d save psbts in binary format by default, add file submenu item for base64 2021-04-02 14:12:48 +02:00
Jimbo e88d6265b4 Adding export button for saving addresses as CSV 2021-04-02 06:29:15 -04:00
Craig Raw 5d91f033c0 add ssl server certificate pinning 2021-04-02 12:27:04 +02:00
Craig Raw 771bd1545c fix export of sparrow wallet from settings screen 2021-04-01 12:41:27 +02:00
Craig Raw f65fec66bb upgrade to hummingbird 1.5.5 2021-04-01 12:09:03 +02:00
Craig Raw 9c6dbeec69 Merge branch 'master' of github.com:sparrowwallet/sparrow 2021-04-01 09:52:37 +02:00
Craig Raw 563af71ed2 log errors on failed qr psbt parsing 2021-04-01 09:52:19 +02:00
craigrawandGitHub a4579c953a Merge pull request #82 from finab1/patch-1
Update README.md
2021-03-27 07:38:38 +02:00
finab1andGitHub 6a3c3de67c Update README.md
Grammer correction in README.
2021-03-27 15:40:47 +11:00
Craig Raw f8fa929166 upgrade to hummingbird 1.5.4 which handles legacy encoding in uppercase 2021-03-24 14:37:06 +02:00
Craig Raw 6aaf532051 v1.3.1 2021-03-24 13:33:41 +02:00
Craig Raw fb59bdfff4 update fee rate priority as fee prediction changes 2021-03-24 13:26:23 +02:00
Craig Raw c8aa1b0a19 increase tab icon size by 1pt 2021-03-24 13:17:49 +02:00
Craig Raw 11a201b3f5 fix webcam opening/closing issues, display progress bar for qr animation scanning progress 2021-03-24 12:59:33 +02:00
Craig Raw b74741bccb add sparrow wallet file export, dont unnecessarily ask for password when exporting 2021-03-22 14:39:58 +02:00
Craig Raw b8739ace4f remove order resources, wait for jdk16 for deterministic jlink builds across servers 2021-03-22 12:02:58 +02:00
Craig Raw cd674ee9be order resources in merged module 2021-03-22 11:31:30 +02:00
Craig Raw 8b3ed8171b fix archive issues 2021-03-20 14:48:24 +02:00
Craig Raw 28ee16279c deterministic builds on linux and windows 2021-03-20 14:13:10 +02:00
Craig Raw ff43db0842 retain messages from rpc errors when loading wallets with bwt 2021-03-20 11:49:17 +02:00
Craig Raw 8ddac9acba reduce target blocks range, predicting too far ahead leads to incorrect estimates 2021-03-19 18:34:53 +02:00
Craig Raw 0c2554da72 add export button to wallet settings 2021-03-19 14:34:29 +02:00
Craig Raw 1274d56fce ensure fees decrease as target blocks increase, show priority on target blocks slider track 2021-03-19 13:35:59 +02:00
Craig Raw 8d9e253f93 update wallet in walletform on label and keystore source changes, detect threshold change 2021-03-19 12:27:08 +02:00
Craig Raw 9166160008 always show wallet name on password dialog 2021-03-18 11:59:59 +02:00
Craig Raw 35eb8fcdf9 add suffix to rbf transaction labels to indicate transaction has been replaced 2021-03-18 11:34:50 +02:00
Craig Raw d34c8b8bde add menu command to install udev rules via hwi on linux 2021-03-17 13:53:38 +02:00
Craig Raw 80a079cbee upgrade to hwi 2.0.0-final 2021-03-17 12:58:22 +02:00
Craig Raw 5d181d1abb upgrade to bwt 0.2.3 2021-03-17 12:39:55 +02:00
Craig Raw 6fbd332b4d add preference option to load recent wallets 2021-03-17 11:59:58 +02:00
Craig Raw 211e5952aa various minor ui improvements 2021-03-17 11:39:46 +02:00
Craig Raw 4078c61d6b add tab label icons, show loading and failure status there 2021-03-16 12:07:55 +02:00
Craig Raw 8b7d1e6888 try proxy setting if internal tor cant start 2021-03-15 14:07:48 +02:00
Craig Raw 7c6daf2e11 scan and import connected devices from import wallet dialog 2021-03-15 13:03:09 +02:00
Craig Raw e1f405d886 default to hd capture on new osx installs 2021-03-15 11:25:30 +02:00
Craig Raw 1d73956853 fixes to minimum and pay then replace fee rates 2021-03-15 10:34:18 +02:00
Craig Raw 205f78151a revert to original hwi file permissions 2021-03-15 09:53:20 +02:00
Craig Raw 5853eb01f5 unpack hwi to all executable file permissions 2021-03-14 12:59:01 +02:00
Craig Raw 1a5e71f852 remove debug on logging config 2021-03-12 18:42:59 +02:00
Craig Raw 7053e7b225 fix logback conflict 2021-03-12 18:32:16 +02:00
Craig Raw 5bee26a8cc followup to jul logging bridge 2021-03-12 18:29:04 +02:00
Craig Raw b9caff352b actually quieten logs by redirecting jul logging to slf4j 2021-03-12 18:14:07 +02:00
Craig Raw 73089ce99c actually quieten logs by redirecting jul logging to slf4j 2021-03-12 18:12:42 +02:00
Craig Raw 34080ae8b3 try to quieten logs with spurious css warnings 2021-03-12 17:29:13 +02:00
Craig Raw 307c7d53f8 add error logging when enumerate fails 2021-03-12 17:19:54 +02:00
Craig Raw a6d2a6563b Merge branch 'master' of github.com:sparrowwallet/sparrow 2021-03-12 17:15:18 +02:00
Craig Raw 577e570c99 update to hwi-2.0.0-rc.3 to fix trezor usage with passphrase 2021-03-12 17:14:23 +02:00
Craig Raw 06411d4e2f update to hwi-2.0.0-rc.3 to fix trezor usage with passphrase 2021-03-12 11:36:11 +02:00
Craig Raw 3e287bfb2d dont start version check service if not on mainnet 2021-03-12 10:46:59 +02:00
111 changed files with 2204 additions and 346 deletions
+9 -1
View File
@@ -32,8 +32,16 @@ jobs:
restore-keys: ${{ runner.os }}-gradle
- name: Build with Gradle
run: ./gradlew jpackage
- name: Package zip distribution
if: ${{ runner.os == 'Windows' }}
run: ./gradlew packageZipDistribution
- name: Package tar distribution
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
uses: actions/upload-artifact@v2
with:
name: Sparrow Build - ${{ runner.os }}
path: build/jpackage/*
path: |
build/jpackage/*
!build/jpackage/Sparrow/
+1 -1
View File
@@ -57,7 +57,7 @@ As a fallback, the network (mainnet, testnet, regtest or signet) can also be set
A final fallback which can be useful when running the Sparrow binary is to create a file called ``network-testnet`` in the Sparrow home folder (see below) to configure the testnet network.
Note that if you are connecting to an Electrum server when using testnet, that server will need to running on testnet configuration as well.
Note that if you are connecting to an Electrum server when using testnet, that server will need to be running on testnet configuration as well.
When not explicitly configured using the command line argument above, Sparrow stores its mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
+29 -4
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.beryx.jlink' version '2.22.0'
}
def sparrowVersion = '1.3.0'
def sparrowVersion = '1.3.2'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -20,7 +20,7 @@ repositories {
maven { url 'https://oss.sonatype.org/content/groups/public' }
maven { url 'https://mymavenrepo.com/repo/29EACwkkGcoOKnbx3bxN/' }
maven { url 'https://jitpack.io' }
maven { url 'http://maven.openimaj.org/' }
maven { url 'https://maven.ecs.soton.ac.uk/content/groups/maven.openimaj.org/' }
}
tasks.withType(AbstractArchiveTask) {
@@ -51,7 +51,7 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.5.2')
implementation('com.sparrowwallet:hummingbird:1.5.5')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
exclude group: 'com.google.android.tools', module: 'dx'
}
@@ -71,6 +71,9 @@ dependencies {
}
implementation('dev.bwt:bwt-jni:0.1.7')
implementation('net.sourceforge.javacsv:javacsv:2.0')
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
testImplementation('junit:junit:4.12')
}
@@ -112,7 +115,7 @@ run {
"--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",
applicationDefaultJvmArgs += ["-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=centerdevice.nsmenufx"]
}
}
@@ -178,3 +181,25 @@ jlink {
}
}
}
task removeGroupWritePermission(type: Exec) {
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
}
task packageZipDistribution(type: Zip) {
archiveFileName = "Sparrow-${sparrowVersion}.zip"
destinationDirectory = file("$buildDir/jpackage")
from("$buildDir/jpackage/") {
include "Sparrow/**"
}
}
task packageTarDistribution(type: Tar) {
dependsOn removeGroupWritePermission
archiveFileName = "sparrow-${sparrowVersion}.tar.gz"
destinationDirectory = file("$buildDir/jpackage")
compression = Compression.GZIP
from("$buildDir/jpackage/") {
include "Sparrow/**"
}
}
+1 -1
Submodule drongo updated: e974c3f422...49654b7c82
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.3.0</string>
<string>1.3.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -21,6 +21,7 @@ import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -49,10 +50,15 @@ import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.*;
import javafx.scene.layout.StackPane;
import javafx.stage.*;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.Window;
import javafx.util.Duration;
import org.controlsfx.control.Notifications;
import org.controlsfx.control.StatusBar;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -61,7 +67,6 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
@@ -70,16 +75,29 @@ public class AppController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(AppController.class);
public static final String DRAG_OVER_CLASS = "drag-over";
public static final double TAB_LABEL_GRAPHIC_OPACITY_INACTIVE = 0.8;
public static final double TAB_LABEL_GRAPHIC_OPACITY_ACTIVE = 0.95;
public static final String LOADING_TRANSACTIONS_MESSAGE = "Loading wallet, select Transactions tab to view...";
public static final String CONNECTION_FAILED_PREFIX = "Connection failed: ";
@FXML
private MenuItem saveTransaction;
@FXML
private Menu savePSBT;
@FXML
private MenuItem exportWallet;
@FXML
private Menu fileMenu;
@FXML
private Menu viewMenu;
@FXML
private Menu toolsMenu;
@FXML
private Menu helpMenu;
@@ -98,9 +116,15 @@ public class AppController implements Initializable {
@FXML
private CheckMenuItem hideEmptyUsedAddresses;
@FXML
private CheckMenuItem useHdCameraResolution;
@FXML
private CheckMenuItem showTxHex;
@FXML
private MenuItem minimizeToTray;
@FXML
private MenuItem refreshWallet;
@@ -128,7 +152,7 @@ public class AppController implements Initializable {
}
void initializeView() {
setOsxApplicationMenu();
setPlatformApplicationMenu();
rootStack.setOnDragOver(event -> {
if(event.getGestureSource() != rootStack && event.getDragboard().hasFiles()) {
@@ -163,7 +187,11 @@ public class AppController implements Initializable {
});
tabs.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedTab) -> {
tabs.getTabs().forEach(tab -> ((Label)tab.getGraphic()).getGraphic().setOpacity(TAB_LABEL_GRAPHIC_OPACITY_INACTIVE));
if(selectedTab != null) {
Label tabLabel = (Label)selectedTab.getGraphic();
tabLabel.getGraphic().setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
EventManager.get().post(new TransactionTabSelectedEvent(selectedTab));
@@ -228,7 +256,9 @@ public class AppController implements Initializable {
openWalletsInNewWindows.setSelected(Config.get().isOpenWalletsInNewWindows());
hideEmptyUsedAddresses.setSelected(Config.get().isHideEmptyUsedAddresses());
useHdCameraResolution.setSelected(Config.get().isHdCapture());
showTxHex.setSelected(Config.get().isShowTransactionHex());
savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
exportWallet.setDisable(true);
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
@@ -246,8 +276,9 @@ public class AppController implements Initializable {
openTransactionIdItem.disableProperty().bind(onlineProperty().not());
}
private void setOsxApplicationMenu() {
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX) {
private void setPlatformApplicationMenu() {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
MenuToolkit tk = MenuToolkit.toolkit();
MenuItem preferences = new MenuItem("Preferences...");
preferences.setOnAction(this::openPreferences);
@@ -259,7 +290,14 @@ public class AppController implements Initializable {
tk.setApplicationMenu(defaultApplicationMenu);
fileMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
toolsMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
helpMenu.getItems().removeIf(item -> item.getStyleClass().contains("osxHide"));
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
toolsMenu.getItems().removeIf(item -> item.getStyleClass().contains("windowsHide"));
}
if(platform == org.controlsfx.tools.Platform.UNIX || !TrayManager.isSupported()) {
viewMenu.getItems().remove(minimizeToTray);
}
}
@@ -313,6 +351,24 @@ public class AppController implements Initializable {
return null;
}
public void installUdevRules(ActionEvent event) {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
Platform.runLater(this::showInstallUdevMessage);
});
enumerateService.setOnFailed(workerStateEvent -> {
Platform.runLater(this::showInstallUdevMessage);
});
enumerateService.start();
}
public void showInstallUdevMessage() {
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules");
dialog.setTitle("Install Udev Rules");
dialog.getDialogPane().setHeaderText("Installing udev rules ensures devices can connect over USB.\nThis command requires root privileges.\nOpen a shell and enter the following:");
dialog.showAndWait();
}
public void openTransactionFromFile(ActionEvent event) {
Stage window = new Stage();
@@ -446,45 +502,27 @@ public class AppController implements Initializable {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
Transaction transaction = transactionTabData.getTransaction();
//Save a transaction if the PSBT is null or transaction has already been extracted, otherwise save PSBT
//The PSBT's transaction is not altered with transaction extraction, but the extracted transaction is stored in TransactionData
boolean saveTx = (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transaction);
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save " + (saveTx ? "Transaction" : "PSBT"));
fileChooser.setTitle("Save Transaction");
String fileName = selectedTab.getText();
String fileName = ((Label)selectedTab.getGraphic()).getText();
if(fileName != null && !fileName.isEmpty()) {
if(transactionTabData.getPsbt() != null) {
if(!fileName.endsWith(".psbt")) {
fileName += ".psbt";
}
} else if(!fileName.endsWith(".txn")) {
fileName += ".txn";
}
if(fileName.endsWith(".psbt")) {
fileName = fileName.substring(0, fileName.length() - ".psbt".length());
}
if(saveTx && fileName.endsWith(".psbt")) {
fileName = fileName.replace(".psbt", "") + ".txn";
}
if(!fileName.endsWith(".txn")) {
fileName += ".txn";
}
fileChooser.setInitialFileName(fileName);
fileChooser.setInitialFileName(fileName);
}
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!saveTx && !file.getName().toLowerCase().endsWith(".psbt")) {
file = new File(file.getAbsolutePath() + ".psbt");
}
try {
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
if(saveTx) {
writer.print(Utils.bytesToHex(transaction.bitcoinSerialize()));
} else {
writer.print(transactionTabData.getPsbt().toBase64String());
}
}
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
writer.print(Utils.bytesToHex(transaction.bitcoinSerialize()));
} catch(IOException e) {
log.error("Error saving transaction", e);
AppServices.showErrorDialog("Error saving transaction", "Cannot write to " + file.getAbsolutePath());
@@ -493,6 +531,58 @@ public class AppController implements Initializable {
}
}
public void savePSBTBinary(ActionEvent event) {
savePSBT(false);
}
public void savePSBTText(ActionEvent event) {
savePSBT(true);
}
public void savePSBT(boolean asText) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save PSBT");
String fileName = ((Label)selectedTab.getGraphic()).getText();
if(fileName != null && !fileName.isEmpty()) {
if(!fileName.endsWith(".psbt")) {
fileName += ".psbt";
}
if(asText) {
fileName += ".txt";
}
fileChooser.setInitialFileName(fileName);
}
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!asText && !file.getName().toLowerCase().endsWith(".psbt")) {
file = new File(file.getAbsolutePath() + ".psbt");
}
try(FileOutputStream outputStream = new FileOutputStream(file)) {
if(asText) {
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.print(transactionTabData.getPsbt().toBase64String());
writer.flush();
} else {
outputStream.write(transactionTabData.getPsbt().serialize());
}
} catch(IOException e) {
log.error("Error saving PSBT", e);
AppServices.showErrorDialog("Error saving PSBT", "Cannot write to " + file.getAbsolutePath());
}
}
}
}
public List<WalletTabData> getOpenWalletTabData() {
List<WalletTabData> openWalletTabData = new ArrayList<>();
@@ -553,6 +643,11 @@ public class AppController implements Initializable {
EventManager.get().post(new HideEmptyUsedAddressesStatusEvent(item.isSelected()));
}
public void useHdCameraResolution(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setHdCapture(item.isSelected());
}
public void showTxHex(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowTransactionHex(item.isSelected());
@@ -802,7 +897,7 @@ public class AppController implements Initializable {
}
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
if(password.get().length() == 0) {
@@ -877,6 +972,10 @@ public class AppController implements Initializable {
messageSignDialog.showAndWait();
}
public void minimizeToTray(ActionEvent event) {
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
}
public void refreshWallet(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
@@ -886,7 +985,7 @@ public class AppController implements Initializable {
Wallet pastWallet = wallet.copy();
walletTabData.getStorage().backupTempWallet();
wallet.clearHistory();
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
}
}
@@ -920,7 +1019,14 @@ public class AppController implements Initializable {
if(!name.equals(wallet.getName())) {
wallet.setName(name);
}
Tab tab = new Tab(name);
Tab tab = new Tab("");
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
glyph.setFontSize(10.0);
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
Label tabLabel = new Label(name);
tabLabel.setGraphic(glyph);
tabLabel.setGraphicTextGap(5.0);
tab.setGraphic(tabLabel);
tab.setContextMenu(getTabContextMenu(tab));
tab.setClosable(true);
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
@@ -1022,7 +1128,7 @@ public class AppController implements Initializable {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
transactionTabData.getPsbt().combine(psbt);
if(name != null && !name.isEmpty()) {
tab.setText(name);
((Label)tab.getGraphic()).setText(name);
}
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
@@ -1037,7 +1143,7 @@ public class AppController implements Initializable {
}
if(name != null && !name.isEmpty()) {
tab.setText(name);
((Label)tab.getGraphic()).setText(name);
}
EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
@@ -1057,7 +1163,14 @@ public class AppController implements Initializable {
tabName = "[" + transaction.getTxId().toString().substring(0, 6) + "]";
}
Tab tab = new Tab(tabName);
Tab tab = new Tab("");
Glyph glyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
glyph.setFontSize(10.0);
glyph.setOpacity(TAB_LABEL_GRAPHIC_OPACITY_ACTIVE);
Label tabLabel = new Label(tabName);
tabLabel.setGraphic(glyph);
tabLabel.setGraphicTextGap(5.0);
tab.setGraphic(tabLabel);
tab.setContextMenu(getTabContextMenu(tab));
tab.setClosable(true);
FXMLLoader transactionLoader = new FXMLLoader(getClass().getResource("transaction/transaction.fxml"));
@@ -1138,6 +1251,57 @@ public class AppController implements Initializable {
EventManager.get().post(new ThemeChangedEvent(selectedTheme));
}
private void tabLabelStartAnimation(Wallet wallet) {
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == wallet).forEach(this::tabLabelStartAnimation);
}
private void tabLabelStartAnimation(Transaction transaction) {
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof TransactionTabData && ((TransactionTabData)tab.getUserData()).getTransaction().getTxId().equals(transaction.getTxId())).forEach(this::tabLabelStartAnimation);
}
private void tabLabelStartAnimation(Tab tab) {
Label tabLabel = (Label) tab.getGraphic();
if(tabLabel.getUserData() == null) {
FadeTransition fadeTransition = new FadeTransition(Duration.millis(1000), tabLabel.getGraphic());
fadeTransition.setFromValue(tabLabel.getGraphic().getOpacity());
fadeTransition.setToValue(0.1);
fadeTransition.setAutoReverse(true);
fadeTransition.setCycleCount(Animation.INDEFINITE);
fadeTransition.play();
tabLabel.setUserData(fadeTransition);
}
}
private void tabLabelAddFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
if(!tabLabel.getStyleClass().contains("failure")) {
tabLabel.getGraphic().getStyleClass().add("failure");
}
}
private void tabLabelStopAnimation(Wallet wallet) {
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == wallet).forEach(this::tabLabelStopAnimation);
}
private void tabLabelStopAnimation(Transaction transaction) {
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof TransactionTabData && ((TransactionTabData)tab.getUserData()).getTransaction().getTxId().equals(transaction.getTxId())).forEach(this::tabLabelStopAnimation);
}
private void tabLabelStopAnimation(Tab tab) {
Label tabLabel = (Label) tab.getGraphic();
if(tabLabel.getUserData() != null) {
FadeTransition fadeTransition = (FadeTransition)tabLabel.getUserData();
fadeTransition.stop();
tabLabel.setUserData(null);
tabLabel.getGraphic().setOpacity(tab.isSelected() ? TAB_LABEL_GRAPHIC_OPACITY_ACTIVE : TAB_LABEL_GRAPHIC_OPACITY_INACTIVE);
}
}
private void tabLabelRemoveFailure(Tab tab) {
Label tabLabel = (Label)tab.getGraphic();
tabLabel.getGraphic().getStyleClass().remove("failure");
}
@Subscribe
public void themeChanged(ThemeChangedEvent event) {
String darkCss = getClass().getResource("darktheme.css").toExternalForm();
@@ -1167,15 +1331,19 @@ public class AppController implements Initializable {
if(event instanceof TransactionTabSelectedEvent) {
TransactionTabSelectedEvent txTabEvent = (TransactionTabSelectedEvent)event;
TransactionTabData transactionTabData = txTabEvent.getTransactionTabData();
saveTransaction.setDisable(false);
saveTransaction.setText("Save " + (transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction() ? "Transaction..." : "PSBT..."));
if(transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction()) {
saveTransaction.setVisible(true);
saveTransaction.setDisable(false);
} else {
saveTransaction.setVisible(false);
}
exportWallet.setDisable(true);
showTxHex.setDisable(false);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
WalletTabData walletTabData = walletTabEvent.getWalletTabData();
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
saveTransaction.setText("Save Transaction...");
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid());
showTxHex.setDisable(true);
}
@@ -1189,14 +1357,15 @@ public class AppController implements Initializable {
if(tabData instanceof TransactionTabData) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
if(transactionTabData.getTransaction() == event.getFinalTransaction()) {
saveTransaction.setText("Save Transaction...");
saveTransaction.setVisible(true);
saveTransaction.setDisable(false);
}
}
}
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
Tab tab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData) {
@@ -1282,6 +1451,7 @@ public class AppController implements Initializable {
@Subscribe
public void versionUpdated(VersionUpdatedEvent event) {
Hyperlink versionUpdateLabel = new Hyperlink("Sparrow " + event.getVersion() + " available");
versionUpdateLabel.getStyleClass().add("version-hyperlink");
versionUpdateLabel.setOnAction(event1 -> {
AppServices.get().getApplication().getHostServices().showDocument("https://www.sparrowwallet.com/download");
});
@@ -1350,8 +1520,7 @@ public class AppController implements Initializable {
@Subscribe
public void connectionFailed(ConnectionFailedEvent event) {
String reason = event.getException().getCause() != null ? event.getException().getCause().getMessage() : event.getException().getMessage();
String status = "Connection failed: " + reason;
String status = CONNECTION_FAILED_PREFIX + event.getMessage();
statusUpdated(new StatusEvent(status));
}
@@ -1364,7 +1533,7 @@ public class AppController implements Initializable {
@Subscribe
public void disconnection(DisconnectionEvent event) {
serverToggle.setDisable(false);
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith("Connection error")) {
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith(CONNECTION_FAILED_PREFIX)) {
statusUpdated(new StatusEvent("Disconnected"));
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
@@ -1375,7 +1544,7 @@ public class AppController implements Initializable {
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
if(event.getClosedWalletTabData().stream().map(WalletTabData::getWallet).anyMatch(loadingWallets::remove) && loadingWallets.isEmpty()) {
if(statusBar.getText().equals("Loading transactions...")) {
if(statusBar.getText().equals(LOADING_TRANSACTIONS_MESSAGE)) {
statusBar.setText("");
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
@@ -1384,30 +1553,50 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void transactionReferences(TransactionReferencesEvent event) {
if(AppServices.isConnected() && event instanceof TransactionReferencesStartedEvent) {
tabLabelStartAnimation(event.getTransaction());
} else {
tabLabelStopAnimation(event.getTransaction());
}
}
@Subscribe
public void walletHistoryStarted(WalletHistoryStartedEvent event) {
if(AppServices.isConnected() && getOpenWallets().containsKey(event.getWallet())) {
statusUpdated(new StatusEvent("Loading transactions...", 120));
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(-1);
loadingWallets.add(event.getWallet());
if(event.getWalletNode() == null && event.getWallet().getTransactions().isEmpty()) {
statusUpdated(new StatusEvent(LOADING_TRANSACTIONS_MESSAGE, 120));
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(-1);
loadingWallets.add(event.getWallet());
}
}
tabLabelStartAnimation(event.getWallet());
}
}
@Subscribe
public void walletHistoryFinished(WalletHistoryFinishedEvent event) {
if(getOpenWallets().containsKey(event.getWallet())) {
if(statusBar.getText().equals("Loading transactions...")) {
if(statusBar.getText().equals(LOADING_TRANSACTIONS_MESSAGE)) {
statusBar.setText("");
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
tabLabelStopAnimation(event.getWallet());
loadingWallets.remove(event.getWallet());
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == event.getWallet()).forEach(this::tabLabelRemoveFailure);
}
}
@Subscribe
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
walletHistoryFinished(new WalletHistoryFinishedEvent(event.getWallet()));
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData) tab.getUserData()).getWallet() == event.getWallet()).forEach(this::tabLabelAddFailure);
}
@Subscribe
public void bwtBootStatus(BwtBootStatusEvent event) {
serverToggle.setDisable(true);
@@ -1467,6 +1656,12 @@ public class AppController implements Initializable {
statusUpdated(new StatusEvent(event.getStatus()));
}
@Subscribe
public void torExternalStatus(TorExternalStatusEvent event) {
serverToggle.setDisable(false);
statusUpdated(new StatusEvent(event.getStatus()));
}
@Subscribe
public void newBlock(NewBlockEvent event) {
setServerToggleTooltip(event.getHeight());
@@ -2,12 +2,14 @@ package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Device;
@@ -63,6 +65,8 @@ public class AppServices {
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
private TrayManager trayManager;
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
private ExchangeSource.RatesService ratesService;
@@ -93,7 +97,7 @@ public class AppServices {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean online) {
if(online) {
if(Config.get().requiresTor() && !isTorRunning()) {
if(Config.get().requiresInternalTor() && !isTorRunning()) {
torService.start();
} else {
restartServices();
@@ -121,7 +125,7 @@ public class AppServices {
onlineProperty.addListener(onlineServicesListener);
if(config.getMode() == Mode.ONLINE) {
if(config.requiresTor()) {
if(config.requiresInternalTor()) {
torService.start();
} else {
restartServices();
@@ -139,7 +143,7 @@ public class AppServices {
restartService(ratesService);
}
if(config.isCheckNewVersions()) {
if(config.isCheckNewVersions() && Network.get() == Network.MAINNET) {
restartService(versionCheckService);
}
}
@@ -207,6 +211,37 @@ public class AppServices {
connectionService.setRestartOnFailure(false);
}
if(failEvent.getSource().getException() instanceof TlsServerException && failEvent.getSource().getException().getCause() != null) {
TlsServerException tlsServerException = (TlsServerException)failEvent.getSource().getException();
connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert();
if(crtFile != null) {
AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nChange the configured server certificate if you would like to proceed.");
} else {
crtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(crtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
crtFile.delete();
Platform.runLater(() -> restartService(connectionService));
return;
}
}
}
}
}
if(failEvent.getSource().getException() instanceof ProxyServerException && Config.get().isUseProxy() && Config.get().requiresTor()) {
Config.get().setUseProxy(false);
Platform.runLater(() -> restartService(torService));
return;
}
onlineProperty.removeListener(onlineServicesListener);
onlineProperty.setValue(false);
onlineProperty.addListener(onlineServicesListener);
@@ -288,6 +323,23 @@ public class AppServices {
EventManager.get().post(new TorReadyStatusEvent());
});
torService.setOnFailed(workerStateEvent -> {
Throwable exception = workerStateEvent.getSource().getException();
if(exception instanceof TorServerAlreadyBoundException) {
String proxyServer = Config.get().getProxyServer();
if(proxyServer == null || proxyServer.equals("")) {
proxyServer = "localhost:9050";
Config.get().setProxyServer(proxyServer);
}
if(proxyServer.equals("localhost:9050") || proxyServer.equals("127.0.0.1:9050")) {
Config.get().setUseProxy(true);
torService.cancel();
restartServices();
EventManager.get().post(new TorExternalStatusEvent());
return;
}
}
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
});
@@ -333,7 +385,7 @@ public class AppServices {
stage.setMinWidth(650);
stage.setMinHeight(730);
stage.setScene(scene);
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow.png")));
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow-large.png")));
appController.initializeView();
stage.show();
@@ -352,6 +404,15 @@ public class AppServices {
return application;
}
public void minimizeStage(Stage stage) {
if(trayManager == null) {
trayManager = new TrayManager();
}
trayManager.addStage(stage);
stage.hide();
}
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
@@ -452,7 +513,7 @@ public class AppServices {
public static void setStageIcon(Window window) {
Stage stage = (Stage)window;
stage.getIcons().add(new Image(AppServices.class.getResourceAsStream("/image/sparrow.png")));
stage.getIcons().add(new Image(AppServices.class.getResourceAsStream("/image/sparrow-large.png")));
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) {
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
@@ -520,7 +581,7 @@ public class AppServices {
public void versionCheckStatus(VersionCheckStatusEvent event) {
versionCheckService.cancel();
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled()) {
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled() && Network.get() == Network.MAINNET) {
versionCheckService = createVersionCheckService();
versionCheckService.start();
}
@@ -539,7 +600,7 @@ public class AppServices {
Platform.runLater(() -> {
if(!Window.getWindows().isEmpty()) {
List<File> walletFiles = allWallets.stream().map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
Config.get().setRecentWalletFiles(walletFiles);
Config.get().setRecentWalletFiles(Config.get().isLoadRecentWallets() ? walletFiles : Collections.emptyList());
}
});
@@ -584,7 +645,7 @@ public class AppServices {
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
restartBwt(event.getWallet());
}
@@ -19,8 +19,10 @@ import javafx.application.Application;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.GlyphFontRegistry;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.bridge.SLF4JBridgeHandler;
import java.io.File;
import java.util.*;
@@ -28,7 +30,7 @@ import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.3.0";
public static final String APP_VERSION = "1.3.2";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -78,6 +80,10 @@ public class MainApp extends Application {
Config.get().setCoreWallet("");
}
if(Config.get().getHdCapture() == null && Platform.getCurrent() == Platform.OSX) {
Config.get().setHdCapture(Boolean.TRUE);
}
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
AppController appController = AppServices.newAppWindow(stage);
@@ -156,6 +162,8 @@ public class MainApp extends Application {
getLogger().info("Using " + Network.get() + " configuration");
}
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
}
@@ -6,7 +6,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -92,7 +92,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, storage.getWalletFile()));
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletFile()));
}
});
if(wallet.getBirthDate() == null) {
@@ -4,16 +4,15 @@ import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.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.event.*;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -34,6 +33,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@@ -197,18 +197,31 @@ public class DevicePane extends TitledDescriptionPane {
importButton.setText("Import Keystore");
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation());
importKeystore(wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation());
});
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(derivation);
});
importButton.getItems().add(item);
if(wallet.getScriptType() == null) {
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH};
for(ScriptType scriptType : scriptTypes) {
MenuItem item = new MenuItem(scriptType.getDescription());
final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
item.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(derivation);
});
importButton.getItems().add(item);
}
} else {
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(derivation);
});
importButton.getItems().add(item);
}
}
importButton.managedProperty().bind(importButton.visibleProperty());
importButton.setVisible(false);
@@ -312,12 +325,22 @@ public class DevicePane extends TitledDescriptionPane {
passphrase.bind(passphraseField.textProperty());
HBox.setHgrow(passphraseField, Priority.ALWAYS);
Button sendPassphraseButton = new Button("Send Passphrase");
SplitMenuButton sendPassphraseButton = new SplitMenuButton();
sendPassphraseButton.setText("Send Passphrase");
sendPassphraseButton.setOnAction(event -> {
setExpanded(false);
setDescription("Confirm passphrase on device...");
sendPassphrase(passphrase.get());
});
MenuItem removePassphrase = new MenuItem("Toggle Passphrase Off");
removePassphrase.setOnAction(event -> {
setExpanded(false);
setDescription("Toggling passphrase off, check device...");
togglePassphraseOff();
});
sendPassphraseButton.getItems().add(removePassphrase);
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
@@ -330,6 +353,35 @@ public class DevicePane extends TitledDescriptionPane {
return contentBox;
}
private Node getTogglePassphraseOn() {
CopyableLabel label = new CopyableLabel("Passphrase is currently disabled");
HBox.setHgrow(label, Priority.ALWAYS);
Button togglePassphraseOn = new Button("Toggle Passphrase On");
togglePassphraseOn.setOnAction(event -> {
setExpanded(false);
hideButtons(importButton, signButton, displayAddressButton, signMessageButton);
setDescription("Toggling passphrase on, check device...");
togglePassphraseOn();
});
HBox contentBox = new HBox();
contentBox.setSpacing(20);
contentBox.setAlignment(Pos.CENTER_LEFT);
contentBox.getChildren().addAll(label, togglePassphraseOn);
contentBox.setPadding(new Insets(10, 30, 10, 30));
return contentBox;
}
private void hideButtons(Node... buttons) {
for(Node button : buttons) {
if(button != null) {
button.setVisible(false);
}
}
}
private void promptPin() {
Hwi.PromptPinService promptPinService = new Hwi.PromptPinService(device);
promptPinService.setOnSucceeded(workerStateEvent -> {
@@ -343,7 +395,7 @@ public class DevicePane extends TitledDescriptionPane {
}
});
promptPinService.setOnFailed(workerStateEvent -> {
setError(promptPinService.getException().getMessage(), null);
setError("Error", promptPinService.getException().getMessage());
unlockButton.setDisable(false);
});
promptPinService.start();
@@ -366,6 +418,7 @@ public class DevicePane extends TitledDescriptionPane {
setExpanded(true);
} else {
showOperationButton();
setContent(getTogglePassphraseOn());
}
} else {
setError("Incorrect PIN", null);
@@ -405,13 +458,43 @@ public class DevicePane extends TitledDescriptionPane {
}
});
enumerateService.setOnFailed(workerStateEvent -> {
setError(enumerateService.getException().getMessage(), null);
setError("Error", enumerateService.getException().getMessage());
setPassphraseButton.setDisable(false);
setPassphraseButton.setVisible(true);
});
enumerateService.start();
}
private void togglePassphraseOff() {
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
togglePassphraseService.setOnSucceeded(workerStateEvent -> {
device.setNeedsPassphraseSent(false);
setPassphraseButton.setVisible(false);
setDescription("Unlocked");
showOperationButton();
});
togglePassphraseService.setOnFailed(workerStateEvent -> {
setError("Error", togglePassphraseService.getException().getMessage());
});
togglePassphraseService.start();
}
private void togglePassphraseOn() {
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
togglePassphraseService.setOnSucceeded(workerStateEvent -> {
device.setNeedsPassphraseSent(true);
setPassphraseButton.setVisible(true);
setPassphraseButton.setDisable(true);
setDescription("Enter passphrase");
setContent(getPassphraseEntry());
setExpanded(true);
});
togglePassphraseService.setOnFailed(workerStateEvent -> {
setError("Error", togglePassphraseService.getException().getMessage());
});
togglePassphraseService.start();
}
private void importKeystore(List<ChildNumber> derivation) {
if(device.getFingerprint() == null) {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(passphrase.get());
@@ -426,7 +509,7 @@ public class DevicePane extends TitledDescriptionPane {
importXpub(derivation);
});
enumerateService.setOnFailed(workerStateEvent -> {
setError(enumerateService.getException().getMessage(), null);
setError("Error", enumerateService.getException().getMessage());
importButton.setDisable(false);
});
enumerateService.start();
@@ -450,7 +533,18 @@ public class DevicePane extends TitledDescriptionPane {
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
EventManager.get().post(new KeystoreImportEvent(keystore));
if(wallet.getScriptType() == null) {
ScriptType scriptType = Arrays.stream(ScriptType.SINGLE_HASH_TYPES).filter(type -> type.getDefaultDerivation().get(0).equals(derivation.get(0))).findFirst().orElse(ScriptType.P2PKH);
wallet.setName(device.getModel().toDisplayString());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
@@ -513,7 +607,7 @@ public class DevicePane extends TitledDescriptionPane {
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(true);
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
setContent(getDerivationEntry(wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
signButton.setVisible(true);
showHideLink.setVisible(false);
@@ -562,6 +656,10 @@ public class DevicePane extends TitledDescriptionPane {
return contentBox;
}
public Device getDevice() {
return device;
}
public enum DeviceOperation {
IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE;
}
@@ -19,14 +19,21 @@ import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class EntryCell extends TreeTableCell<Entry, Entry> {
private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
private static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*)\\(Replaced By Fee( #)?(\\d+)?\\).*");
public EntryCell() {
super();
@@ -200,12 +207,32 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
externalOutputs.addAll(consolidationOutputs);
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
return new Payment(txOutput.getScript().getToAddresses()[0], transactionEntry.getLabel(), txOutput.getValue(), false);
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(label);
if(matcher.matches()) {
String base = matcher.group(1);
if(matcher.groupCount() > 2 && matcher.group(3) != null) {
int count = Integer.parseInt(matcher.group(3)) + 1;
label = base + "(Replaced By Fee #" + count + ")";
} else {
label = base + "(Replaced By Fee #2)";
}
} else {
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
}
return new Payment(txOutput.getScript().getToAddresses()[0], label, txOutput.getValue(), false);
} catch(Exception e) {
log.error("Error creating RBF payment", e);
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toList());
if(payments.isEmpty()) {
AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction, check log for details");
return;
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true)));
}
@@ -10,6 +10,7 @@ import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CoboVaultMultisig;
import com.sparrowwallet.sparrow.io.PassportMultisig;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.WalletExport;
import javafx.geometry.Pos;
@@ -81,7 +82,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
fileChooser.setInitialFileName(wallet.getName() + "-" + exporter.getWalletModel().toDisplayString().toLowerCase() + "." + exporter.getExportFileExtension());
String extension = exporter.getExportFileExtension(wallet);
fileChooser.setInitialFileName(wallet.getName() + "-" +
exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "") +
(extension == null || extension.isEmpty() ? "" : "." + extension));
File file = fileChooser.showSaveDialog(window);
if(file != null) {
@@ -90,10 +94,9 @@ public class FileWalletExportPane extends TitledDescriptionPane {
}
private void exportWallet(File file) {
Wallet copy = wallet.copy();
if(copy.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final File walletFile = AppServices.get().getOpenWallets().get(wallet).getWalletFile();
@@ -101,7 +104,12 @@ public class FileWalletExportPane extends TitledDescriptionPane {
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
exportWallet(file, decryptedWallet);
try {
exportWallet(file, decryptedWallet);
} finally {
decryptedWallet.clearPrivate();
}
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Failed"));
@@ -111,22 +119,24 @@ public class FileWalletExportPane extends TitledDescriptionPane {
decryptWalletService.start();
}
} else {
exportWallet(file, copy);
exportWallet(file, wallet);
}
}
private void exportWallet(File file, Wallet decryptedWallet) {
private void exportWallet(File file, Wallet exportWallet) {
try {
if(file != null) {
OutputStream outputStream = new FileOutputStream(file);
exporter.exportWallet(decryptedWallet, outputStream);
EventManager.get().post(new WalletExportEvent(decryptedWallet));
exporter.exportWallet(exportWallet, outputStream);
EventManager.get().post(new WalletExportEvent(exportWallet));
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(decryptedWallet, outputStream);
exporter.exportWallet(exportWallet, outputStream);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
}
@@ -138,8 +148,6 @@ public class FileWalletExportPane extends TitledDescriptionPane {
errorMessage = e.getCause().getMessage();
}
setError("Export Error", errorMessage);
} finally {
decryptedWallet.clearPrivate();
}
}
}
@@ -308,7 +308,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return;
}
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
@@ -30,6 +30,7 @@ import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
@@ -275,7 +276,14 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
int mnemonicSeedLength = wordEntriesProperty.get().size() * 11;
int entropyLength = mnemonicSeedLength - (mnemonicSeedLength/33);
DeterministicSeed deterministicSeed = new DeterministicSeed(new SecureRandom(), entropyLength, "");
SecureRandom secureRandom;
try {
secureRandom = SecureRandom.getInstanceStrong();
} catch(NoSuchAlgorithmException e) {
secureRandom = new SecureRandom();
}
DeterministicSeed deterministicSeed = new DeterministicSeed(secureRandom, entropyLength, "");
generatedMnemonicCode = deterministicSeed.getMnemonicCode();
displayMnemonicCode();
@@ -392,6 +400,19 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
label.setAlignment(Pos.CENTER_RIGHT);
wordField = new TextField();
wordField.setMaxWidth(100);
TextFormatter<?> formatter = new TextFormatter<>((TextFormatter.Change change) -> {
String text = change.getText();
// if text was added, fix the text to fit the requirements
if(!text.isEmpty()) {
String newText = text.replace(" ", "").toLowerCase();
int carretPos = change.getCaretPosition() - text.length() + newText.length();
change.setText(newText);
// fix caret position based on difference in originally added text and fixed text
change.selectRange(carretPos, carretPos);
}
return change;
});
wordField.setTextFormatter(formatter);
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamEvent;
import com.github.sarxos.webcam.WebcamListener;
import com.github.sarxos.webcam.WebcamResolution;
import com.github.sarxos.webcam.WebcamUpdater;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
@@ -16,6 +17,7 @@ import com.sparrowwallet.drongo.protocol.Base43;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -28,13 +30,17 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import javafx.application.Platform;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.slf4j.Logger;
@@ -59,13 +65,15 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private final WebcamService webcamService;
private List<String> parts;
private boolean isUr;
private QRScanDialog.Result result;
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
private static final int SCAN_PERIOD_MILLIS = 100;
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA);
private final DoubleProperty percentComplete = new SimpleDoubleProperty(0.0);
public QRScanDialog() {
this.decoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder();
@@ -74,7 +82,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
webcamResolutionProperty.set(WebcamResolution.HD);
}
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener());
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener(), new ScanDelayCalculator());
webcamService.setPeriod(Duration.millis(SCAN_PERIOD_MILLIS));
webcamService.setRestartOnFailure(false);
WebcamView webcamView = new WebcamView(webcamService);
final DialogPane dialogPane = new QRScanDialogPane();
@@ -83,8 +93,23 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
StackPane stackPane = new StackPane();
stackPane.getChildren().add(webcamView.getView());
Node wrappedView = Borders.wrap(stackPane).lineBorder().buildAll();
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
ProgressBar progressBar = new ProgressBar();
progressBar.setMinHeight(20);
progressBar.setPadding(new Insets(0, 10, 0, 10));
progressBar.setPrefWidth(Integer.MAX_VALUE);
progressBar.progressProperty().bind(percentComplete);
webcamService.openingProperty().addListener((observable, oldValue, newValue) -> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(newValue ? 0.0 : -1.0));
}
});
VBox vBox = new VBox(20);
vBox.getChildren().addAll(wrappedView, progressBar);
dialogPane.setContent(vBox);
webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> {
@@ -111,7 +136,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().addAll(hdButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 450 : 550);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
}
@@ -127,11 +152,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
String qrtext = qrResult.getText();
Matcher partMatcher = PART_PATTERN.matcher(qrtext);
if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
isUr = true;
if(qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) {
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext.toLowerCase());
legacyDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete()));
if(legacyDecoder.isComplete()) {
try {
@@ -143,6 +167,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
} else {
decoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getEstimatedPercentComplete()));
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
@@ -164,12 +189,20 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
parts.set(m - 1, payload);
if(n > 0) {
Platform.runLater(() -> percentComplete.setValue((double)parts.stream().filter(Objects::nonNull).count() / n));
}
if(parts.stream().filter(Objects::nonNull).count() == n) {
String complete = String.join("", parts);
try {
PSBT psbt = PSBT.fromString(complete);
result = new Result(psbt);
return;
} catch(PSBTParseException e) {
if(PSBT.isPSBT(complete)) {
log.error("Error parsing PSBT", e);
}
} catch(Exception e) {
//ignore, bytes not parsable as PSBT
}
@@ -218,6 +251,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
psbt = PSBT.fromString(qrtext);
result = new Result(psbt);
return;
} catch(PSBTParseException e) {
if(PSBT.isPSBT(qrtext)) {
log.error("Error parsing PSBT", e);
}
} catch(Exception e) {
//Ignore, not parseable as Base64 or hex
}
@@ -276,6 +313,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
try {
PSBT psbt = new PSBT(urBytes);
return new Result(psbt);
} catch(PSBTParseException e) {
if(PSBT.isPSBT(urBytes)) {
log.error("Error parsing PSBT", e);
}
} catch(Exception e) {
//ignore, bytes not parsable as PSBT
}
@@ -484,6 +525,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private class QRScanDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button = null;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton hd = new ToggleButton(buttonType.getText());
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
@@ -497,10 +539,13 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
setHdGraphic(hd, newValue);
});
return hd;
button = hd;
} else {
button = super.createButton(buttonType);
}
return super.createButton(buttonType);
button.disableProperty().bind(webcamService.openingProperty());
return button;
}
private void setHdGraphic(ToggleButton hd, boolean isHd) {
@@ -663,4 +708,10 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
super(message, cause);
}
}
public static class ScanDelayCalculator implements WebcamUpdater.DelayCalculator {
public long calculateDelay(long snapshotDuration, double deviceFps) {
return Math.max(SCAN_PERIOD_MILLIS - snapshotDuration, 0L);
}
}
}
@@ -4,6 +4,8 @@ import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
@@ -18,6 +20,9 @@ public class TextAreaDialog extends Dialog<String> {
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue) {
final DialogPane dialogPane = getDialogPane();
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
HBox hbox = new HBox();
this.textArea = new TextArea(defaultValue);
this.textArea.setMaxWidth(Double.MAX_VALUE);
@@ -0,0 +1,102 @@
package com.sparrowwallet.sparrow.control;
import javafx.application.Platform;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class TrayManager {
private static final Logger log = LoggerFactory.getLogger(TrayManager.class);
private final SystemTray tray;
private final TrayIcon trayIcon;
private final PopupMenu popupMenu = new PopupMenu();
public TrayManager() {
if(!SystemTray.isSupported()) {
throw new UnsupportedOperationException("SystemTray icons are not supported by the current desktop environment.");
}
tray = SystemTray.getSystemTray();
try {
List<Image> imgList = new ArrayList<>();
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS) {
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-black-small.png")));
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-black-small@2x.png")));
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-black-small@3x.png")));
} else {
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-white-small.png")));
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-white-small@2x.png")));
imgList.add(ImageIO.read(getClass().getResource("/image/sparrow-white-small@3x.png")));
}
BaseMultiResolutionImage mrImage = new BaseMultiResolutionImage(imgList.toArray(new Image[0]));
this.trayIcon = new TrayIcon(mrImage, "Sparrow", popupMenu);
MenuItem miExit = new MenuItem("Quit Sparrow");
miExit.addActionListener(e -> {
SwingUtilities.invokeLater(() -> { tray.remove(this.trayIcon); });
Platform.exit();
});
this.popupMenu.add(miExit);
} catch(IOException e) {
log.error("Could not load system tray image", e);
throw new IllegalStateException(e);
}
}
public void addStage(Stage stage) {
EventQueue.invokeLater(() -> {
MenuItem miStage = new MenuItem(stage.getTitle());
miStage.setFont(Font.decode(null).deriveFont(Font.BOLD));
miStage.addActionListener(e -> Platform.runLater(() -> {
stage.show();
EventQueue.invokeLater(() -> {
popupMenu.remove(miStage);
if(popupMenu.getItemCount() == 1) {
Platform.setImplicitExit(true);
SwingUtilities.invokeLater(() -> tray.remove(trayIcon));
}
});
}));
//Make sure it's always at the top
this.popupMenu.insert(miStage,popupMenu.getItemCount() - 1);
if(!isShowing()) {
// Keeps the JVM running even if there are no
// visible JavaFX Stages, otherwise JVM would
// exit and we lose the TrayIcon
Platform.setImplicitExit(false);
SwingUtilities.invokeLater(() -> {
try {
tray.add(this.trayIcon);
} catch(AWTException e) {
log.error("Unable to add system tray icon", e);
}
});
}
});
}
public boolean isShowing() {
return Arrays.stream(tray.getTrayIcons()).collect(Collectors.toList()).contains(trayIcon);
}
public static boolean isSupported() {
return Desktop.isDesktopSupported() && SystemTray.isSupported();
}
}
@@ -41,9 +41,9 @@ public class WalletExportDialog extends Dialog<Wallet> {
List<WalletExport> exporters;
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new SpecterDesktop());
exporters = List.of(new Electrum(), new SpecterDesktop(), new Sparrow());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig());
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -4,16 +4,24 @@ import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.UsbDeviceEvent;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.*;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import java.util.List;
public class WalletImportDialog extends Dialog<Wallet> {
private Wallet wallet;
private final Accordion importAccordion;
private final Button scanButton;
public WalletImportDialog() {
EventManager.get().register(this);
@@ -38,7 +46,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
Accordion importAccordion = new Accordion();
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
@@ -53,7 +61,16 @@ public class WalletImportDialog extends Dialog<Wallet> {
scrollPane.setContent(importAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan for Connected Devices", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().addAll(scanButtonType, cancelButtonType);
scanButton = (Button) dialogPane.lookupButton(scanButtonType);
scanButton.setGraphic(new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB));
scanButton.addEventFilter(ActionEvent.ACTION, event -> {
scan();
event.consume();
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(500);
@@ -65,4 +82,26 @@ public class WalletImportDialog extends Dialog<Wallet> {
wallet = event.getWallet();
setResult(wallet);
}
private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
scanButton.setGraphic(new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB));
scanButton.setTooltip(null);
List<Device> devices = enumerateService.getValue();
importAccordion.getPanes().removeIf(titledPane -> titledPane instanceof DevicePane);
for(Device device : devices) {
DevicePane devicePane = new DevicePane(new Wallet(), device);
importAccordion.getPanes().add(0, devicePane);
}
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
});
enumerateService.setOnFailed(workerStateEvent -> {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
glyph.getStyleClass().add("failure");
scanButton.setGraphic(glyph);
scanButton.setTooltip(new Tooltip(workerStateEvent.getSource().getException().getMessage()));
});
enumerateService.start();
}
}
@@ -25,12 +25,8 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
private final CheckBox deleteBackups;
private boolean addingPassword;
public WalletPasswordDialog(PasswordRequirement requirement) {
this(null, requirement);
}
public WalletPasswordDialog(String walletName, PasswordRequirement requirement) {
this(null, requirement, false);
this(walletName, requirement, false);
}
public WalletPasswordDialog(String walletName, PasswordRequirement requirement, boolean suggestChangePassword) {
@@ -135,10 +131,10 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
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_NEW("Add a password to the wallet?\nLeave empty for no password:", "No Password"),
UPDATE_EMPTY("This wallet has no password.\nAdd a password to the wallet?\nLeave empty for no password:", "No Password"),
UPDATE_SET("Please re-enter the wallet password:", "Verify Password"),
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for none:", "No Password");
UPDATE_CHANGE("Enter the new wallet password.\nLeave empty for no password:", "No Password");
private final String description;
private final String okButtonText;
@@ -0,0 +1,354 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.*;
import com.github.sarxos.webcam.ds.buildin.natives.Device;
import com.github.sarxos.webcam.ds.buildin.natives.DeviceList;
import com.github.sarxos.webcam.ds.buildin.natives.OpenIMAJGrabber;
import org.bridj.Pointer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.color.ColorSpace;
import java.awt.image.*;
import java.nio.ByteBuffer;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@SuppressWarnings("deprecation")
public class WebcamScanDevice implements WebcamDevice, WebcamDevice.BufferAccess, Runnable, WebcamDevice.FPSSource {
private static final Logger LOG = LoggerFactory.getLogger(WebcamScanDevice.class);
private static final int DEVICE_BUFFER_SIZE = 5;
private static final Dimension[] DIMENSIONS;
private static final int[] BAND_OFFSETS;
private static final int[] BITS;
private static final int[] OFFSET;
private static final int DATA_TYPE = 0;
private static final ColorSpace COLOR_SPACE;
public static final int SCAN_LOOP_WAIT_MILLIS = 100;
private int timeout = 5000;
private OpenIMAJGrabber grabber = null;
private Device device = null;
private Dimension size = null;
private ComponentSampleModel smodel = null;
private ColorModel cmodel = null;
private boolean failOnSizeMismatch = false;
private final AtomicBoolean disposed = new AtomicBoolean(false);
private final AtomicBoolean open = new AtomicBoolean(false);
private final AtomicBoolean fresh = new AtomicBoolean(false);
private Thread refresher = null;
private String name = null;
private String id = null;
private String fullname = null;
private long t1 = -1L;
private long t2 = -1L;
private volatile double fps = 0.0D;
protected WebcamScanDevice(Device device) {
this.device = device;
this.name = device.getNameStr();
this.id = device.getIdentifierStr();
this.fullname = String.format("%s %s", this.name, this.id);
}
public String getName() {
return this.fullname;
}
public String getDeviceName() {
return this.name;
}
public String getDeviceId() {
return this.id;
}
public Device getDeviceRef() {
return this.device;
}
public Dimension[] getResolutions() {
return DIMENSIONS;
}
public Dimension getResolution() {
if (this.size == null) {
this.size = this.getResolutions()[0];
}
return this.size;
}
public void setResolution(Dimension size) {
if (size == null) {
throw new IllegalArgumentException("Size cannot be null");
} else if (this.open.get()) {
throw new IllegalStateException("Cannot change resolution when webcam is open, please close it first");
} else {
this.size = size;
}
}
public ByteBuffer getImageBytes() {
if (this.disposed.get()) {
LOG.debug("Webcam is disposed, image will be null");
return null;
} else if (!this.open.get()) {
LOG.debug("Webcam is closed, image will be null");
return null;
} else {
if (this.fresh.compareAndSet(false, true)) {
this.updateFrameBuffer();
}
LOG.trace("Webcam grabber get image pointer");
Pointer<Byte> image = this.grabber.getImage();
this.fresh.set(false);
if (image == null) {
LOG.warn("Null array pointer found instead of image");
return null;
} else {
int length = this.size.width * this.size.height * 3;
LOG.trace("Webcam device get buffer, read {} bytes", length);
return image.getByteBuffer((long)length);
}
}
}
public void getImageBytes(ByteBuffer target) {
if (this.disposed.get()) {
LOG.debug("Webcam is disposed, image will be null");
} else if (!this.open.get()) {
LOG.debug("Webcam is closed, image will be null");
} else {
int minSize = this.size.width * this.size.height * 3;
int curSize = target.remaining();
if (minSize > curSize) {
throw new IllegalArgumentException(String.format("Not enough remaining space in target buffer (%d necessary vs %d remaining)", minSize, curSize));
} else {
if (this.fresh.compareAndSet(false, true)) {
this.updateFrameBuffer();
}
LOG.trace("Webcam grabber get image pointer");
Pointer<Byte> image = this.grabber.getImage();
this.fresh.set(false);
if (image == null) {
LOG.warn("Null array pointer found instead of image");
} else {
LOG.trace("Webcam device read buffer {} bytes", minSize);
image = image.validBytes((long)minSize);
image.getBytes(target);
}
}
}
}
public BufferedImage getImage() {
ByteBuffer buffer = this.getImageBytes();
if (buffer == null) {
LOG.error("Images bytes buffer is null!");
return null;
} else {
byte[] bytes = new byte[this.size.width * this.size.height * 3];
byte[][] data = new byte[][]{bytes};
buffer.get(bytes);
DataBufferByte dbuf = new DataBufferByte(data, bytes.length, OFFSET);
WritableRaster raster = Raster.createWritableRaster(this.smodel, dbuf, (Point)null);
BufferedImage bi = new BufferedImage(this.cmodel, raster, false, (Hashtable)null);
bi.flush();
return bi;
}
}
public void open() {
if (!this.disposed.get()) {
LOG.debug("Opening webcam device {}", this.getName());
if (this.size == null) {
this.size = this.getResolutions()[0];
}
if (this.size == null) {
throw new RuntimeException("The resolution size cannot be null");
} else {
LOG.debug("Webcam device {} starting session, size {}", this.device.getIdentifierStr(), this.size);
this.grabber = new OpenIMAJGrabber();
DeviceList list = (DeviceList)this.grabber.getVideoDevices().get();
Iterator var2 = list.asArrayList().iterator();
while(var2.hasNext()) {
Device d = (Device)var2.next();
d.getNameStr();
d.getIdentifierStr();
}
boolean started = this.grabber.startSession(this.size.width, this.size.height, 50, Pointer.pointerTo(this.device));
if (!started) {
throw new WebcamException("Cannot start native grabber!");
} else {
this.grabber.setTimeout(this.timeout);
LOG.debug("Webcam device session started");
Dimension size2 = new Dimension(this.grabber.getWidth(), this.grabber.getHeight());
int w1 = this.size.width;
int w2 = size2.width;
int h1 = this.size.height;
int h2 = size2.height;
if (w1 != w2 || h1 != h2) {
if (this.failOnSizeMismatch) {
throw new WebcamException(String.format("Different size obtained vs requested - [%dx%d] vs [%dx%d]", w1, h1, w2, h2));
}
Object[] args = new Object[]{w1, h1, w2, h2, w2, h2};
LOG.warn("Different size obtained vs requested - [{}x{}] vs [{}x{}]. Setting correct one. New size is [{}x{}]", args);
this.size = new Dimension(w2, h2);
}
this.smodel = new ComponentSampleModel(0, this.size.width, this.size.height, 3, this.size.width * 3, BAND_OFFSETS);
this.cmodel = new ComponentColorModel(COLOR_SPACE, BITS, false, false, 1, 0);
LOG.debug("Clear memory buffer");
this.clearMemoryBuffer();
LOG.debug("Webcam device {} is now open", this);
this.open.set(true);
this.refresher = this.startFramesRefresher();
}
}
}
}
private void clearMemoryBuffer() {
for(int i = 0; i < 5; ++i) {
this.grabber.nextFrame();
}
}
private Thread startFramesRefresher() {
Thread refresher = new Thread(this, String.format("frames-refresher-[%s]", this.id));
refresher.setUncaughtExceptionHandler(WebcamExceptionHandler.getInstance());
refresher.setDaemon(true);
refresher.start();
return refresher;
}
public void close() {
if (this.open.compareAndSet(true, false)) {
LOG.debug("Closing webcam device");
this.grabber.stopSession();
}
}
public void dispose() {
if (this.disposed.compareAndSet(false, true)) {
LOG.debug("Disposing webcam device {}", this.getName());
this.close();
}
}
public void setFailOnSizeMismatch(boolean fail) {
this.failOnSizeMismatch = fail;
}
public boolean isOpen() {
return this.open.get();
}
public int getTimeout() {
return this.timeout;
}
public void setTimeout(int timeout) {
if (this.isOpen()) {
throw new WebcamException("Timeout must be set before webcam is open");
} else {
this.timeout = timeout;
}
}
private void updateFrameBuffer() {
LOG.trace("Next frame");
if (this.t1 == -1L || this.t2 == -1L) {
this.t1 = System.currentTimeMillis();
this.t2 = System.currentTimeMillis();
}
int result = (new WebcamScanDevice.NextFrameTask(this)).nextFrame();
this.t1 = this.t2;
this.t2 = System.currentTimeMillis();
this.fps = (4.0D * this.fps + (double)(1000L / (this.t2 - this.t1 + 1L))) / 5.0D;
if (result == -1) {
LOG.error("Timeout when requesting image!");
} else if (result < -1) {
LOG.error("Error requesting new frame!");
}
}
public void run() {
do {
try {
Thread.sleep(SCAN_LOOP_WAIT_MILLIS);
} catch(InterruptedException e) {
//ignore
}
if (Thread.interrupted()) {
LOG.debug("Refresher has been interrupted");
return;
}
if (!this.open.get()) {
LOG.debug("Cancelling refresher");
return;
}
this.updateFrameBuffer();
} while(this.open.get());
}
public double getFPS() {
return this.fps;
}
static {
DIMENSIONS = new Dimension[]{WebcamResolution.QQVGA.getSize(), WebcamResolution.QVGA.getSize(), WebcamResolution.VGA.getSize()};
BAND_OFFSETS = new int[]{0, 1, 2};
BITS = new int[]{8, 8, 8};
OFFSET = new int[]{0};
COLOR_SPACE = ColorSpace.getInstance(1000);
}
private class NextFrameTask extends WebcamTask {
private final AtomicInteger result = new AtomicInteger(0);
public NextFrameTask(WebcamDevice device) {
super(device);
}
public int nextFrame() {
try {
this.process();
} catch (InterruptedException var2) {
WebcamScanDevice.LOG.debug("Image buffer request interrupted", var2);
}
return this.result.get();
}
protected void handle() {
WebcamScanDevice device = (WebcamScanDevice)this.getDevice();
if (device.isOpen()) {
try {
Thread.sleep(SCAN_LOOP_WAIT_MILLIS);
} catch(InterruptedException e) {
//ignore
}
this.result.set(WebcamScanDevice.this.grabber.nextFrame());
WebcamScanDevice.this.fresh.set(true);
}
}
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.WebcamDevice;
import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDevice;
import com.github.sarxos.webcam.ds.buildin.WebcamDefaultDriver;
import java.util.ArrayList;
import java.util.List;
public class WebcamScanDriver extends WebcamDefaultDriver {
@Override
public List<WebcamDevice> getDevices() {
List<WebcamDevice> devices = super.getDevices();
List<WebcamDevice> scanDevices = new ArrayList<>();
for(WebcamDevice device : devices) {
WebcamDefaultDevice defaultDevice = (WebcamDefaultDevice)device;
scanDevices.add(new WebcamScanDevice(defaultDevice.getDeviceRef()));
}
return scanDevices;
}
}
@@ -3,29 +3,46 @@ package com.sparrowwallet.sparrow.control;
import com.github.sarxos.webcam.Webcam;
import com.github.sarxos.webcam.WebcamListener;
import com.github.sarxos.webcam.WebcamResolution;
import com.github.sarxos.webcam.WebcamUpdater;
import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class WebcamService extends Service<Image> {
public class WebcamService extends ScheduledService<Image> {
private WebcamResolution resolution;
private final WebcamListener listener;
private final WebcamUpdater.DelayCalculator delayCalculator;
private final BooleanProperty opening = new SimpleBooleanProperty(false);
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
public WebcamService(WebcamResolution resolution, WebcamListener listener) {
private static final int QR_SAMPLE_PERIOD_MILLIS = 400;
private Webcam cam;
private long lastQrSampleTime;
static {
Webcam.setDriver(new WebcamScanDriver());
}
public WebcamService(WebcamResolution resolution, WebcamListener listener, WebcamUpdater.DelayCalculator delayCalculator) {
this.resolution = resolution;
this.listener = listener;
this.delayCalculator = delayCalculator;
this.lastQrSampleTime = System.currentTimeMillis();
}
@Override
@@ -33,31 +50,57 @@ public class WebcamService extends Service<Image> {
return new Task<Image>() {
@Override
protected Image call() throws Exception {
Webcam cam = Webcam.getWebcams(1, TimeUnit.MINUTES).get(0);
try {
cam.setCustomViewSizes(resolution.getSize());
cam.setViewSize(resolution.getSize());
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
cam.addWebcamListener(listener);
if(cam == null) {
List<Webcam> webcams = Webcam.getWebcams(1, TimeUnit.MINUTES);
if(webcams.isEmpty()) {
throw new UnsupportedOperationException("No camera available.");
}
cam = webcams.get(0);
cam.setCustomViewSizes(resolution.getSize());
cam.setViewSize(resolution.getSize());
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
cam.addWebcamListener(listener);
}
opening.set(true);
cam.open(true, delayCalculator);
opening.set(false);
}
cam.open();
while(!isCancelled()) {
if(cam.isImageNew()) {
BufferedImage bimg = cam.getImage();
updateValue(SwingFXUtils.toFXImage(bimg, null));
readQR(bimg);
}
BufferedImage bimg = cam.getImage();
Image image = SwingFXUtils.toFXImage(bimg, null);
updateValue(image);
if(System.currentTimeMillis() > (lastQrSampleTime + QR_SAMPLE_PERIOD_MILLIS)) {
readQR(bimg);
lastQrSampleTime = System.currentTimeMillis();
}
cam.close();
return getValue();
return image;
} finally {
cam.close();
opening.set(false);
}
}
};
}
@Override
public void reset() {
cam = null;
super.reset();
}
@Override
public boolean cancel() {
if(cam != null && !cam.close()) {
cam.close();
}
return super.cancel();
}
private void readQR(BufferedImage bufferedImage) {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
@@ -89,4 +132,12 @@ public class WebcamService extends Service<Image> {
public void setResolution(WebcamResolution resolution) {
this.resolution = resolution;
}
public boolean isOpening() {
return opening.get();
}
public BooleanProperty openingProperty() {
return opening;
}
}
@@ -1,7 +1,10 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.Region;
import org.slf4j.Logger;
@@ -14,7 +17,9 @@ public class WebcamView {
private final WebcamService service;
private final Region view;
private final Label statusPlaceholder ;
private final Label statusPlaceholder;
private final ObjectProperty<Image> imageProperty = new SimpleObjectProperty<>(null);
public WebcamView(WebcamService service) {
this.service = service ;
@@ -23,22 +28,32 @@ public class WebcamView {
// make the cam behave like a mirror:
imageView.setScaleX(-1);
service.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
imageProperty.set(newValue);
}
});
this.statusPlaceholder = new Label();
this.view = new Region() {
{
service.stateProperty().addListener((obs, oldState, newState) -> {
switch (newState) {
case READY:
statusPlaceholder.setText("Initializing");
getChildren().setAll(statusPlaceholder);
if(imageProperty.get() == null) {
statusPlaceholder.setText("Initializing");
getChildren().setAll(statusPlaceholder);
}
break ;
case SCHEDULED:
statusPlaceholder.setText("Waiting");
getChildren().setAll(statusPlaceholder);
if(imageProperty.get() == null) {
statusPlaceholder.setText("Waiting");
getChildren().setAll(statusPlaceholder);
}
break ;
case RUNNING:
imageView.imageProperty().unbind();
imageView.imageProperty().bind(service.valueProperty());
imageView.imageProperty().bind(imageProperty);
getChildren().setAll(imageView);
break ;
case CANCELLED:
@@ -1,23 +1,18 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import java.util.Map;
public class BlockTransactionFetchedEvent extends PagedEvent {
public class BlockTransactionFetchedEvent extends TransactionReferencesFinishedEvent {
private final Sha256Hash txId;
private final BlockTransaction blockTransaction;
private final Map<Sha256Hash, BlockTransaction> inputTransactions;
public BlockTransactionFetchedEvent(Sha256Hash txId, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions) {
this(txId, blockTransaction, inputTransactions, 0, blockTransaction.getTransaction().getInputs().size());
}
public BlockTransactionFetchedEvent(Sha256Hash txId, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions, int pageStart, int pageEnd) {
super(pageStart, pageEnd);
this.txId = txId;
this.blockTransaction = blockTransaction;
public BlockTransactionFetchedEvent(Transaction transaction, BlockTransaction blockTransaction, Map<Sha256Hash, BlockTransaction> inputTransactions, int pageStart, int pageEnd) {
super(transaction, blockTransaction, pageStart, pageEnd);
this.txId = transaction.getTxId();
this.inputTransactions = inputTransactions;
}
@@ -25,10 +20,6 @@ public class BlockTransactionFetchedEvent extends PagedEvent {
return txId;
}
public BlockTransaction getBlockTransaction() {
return blockTransaction;
}
public Map<Sha256Hash, BlockTransaction> getInputTransactions() {
return inputTransactions;
}
@@ -1,17 +1,18 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import java.util.List;
public class BlockTransactionOutputsFetchedEvent extends PagedEvent {
public class BlockTransactionOutputsFetchedEvent extends TransactionReferencesEvent {
private final Sha256Hash txId;
private final List<BlockTransaction> outputTransactions;
public BlockTransactionOutputsFetchedEvent(Sha256Hash txId, List<BlockTransaction> outputTransactions, int pageStart, int pageEnd) {
super(pageStart, pageEnd);
this.txId = txId;
public BlockTransactionOutputsFetchedEvent(Transaction transaction, List<BlockTransaction> outputTransactions, int pageStart, int pageEnd) {
super(transaction, pageStart, pageEnd);
this.txId = transaction.getTxId();
this.outputTransactions = outputTransactions;
}
@@ -1,5 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.TlsServerException;
public class ConnectionFailedEvent {
private final Throwable exception;
@@ -10,4 +12,26 @@ public class ConnectionFailedEvent {
public Throwable getException() {
return exception;
}
public String getMessage() {
if(exception instanceof TlsServerException) {
return exception.getMessage();
}
Throwable cause = (exception.getCause() != null ? exception.getCause() : exception);
cause = (cause.getCause() != null ? cause.getCause() : cause);
String message = splitCamelCase(cause.getClass().getSimpleName().replace("Exception", "Error"));
return message + " (" + cause.getMessage() + ")";
}
static String splitCamelCase(String s) {
return s.replaceAll(
String.format("%s|%s|%s",
"(?<=[A-Z])(?=[A-Z][a-z])",
"(?<=[^A-Z])(?=[A-Z])",
"(?<=[A-Za-z])(?=[^A-Za-z])"
),
" "
);
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.TabData;
import javafx.scene.control.Label;
import javafx.scene.control.Tab;
public class TabEvent {
@@ -15,7 +16,7 @@ public class TabEvent {
}
public String getTabName() {
return tab.getText();
return ((Label)tab.getGraphic()).getText();
}
public TabData getTabData() {
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.event;
public class TorExternalStatusEvent extends TorStatusEvent {
public TorExternalStatusEvent() {
super("Tor is already running, using external instance...");
}
}
@@ -1,12 +1,13 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.TorServerAlreadyBoundException;
public class TorFailedStatusEvent extends TorStatusEvent {
private final Throwable exception;
public TorFailedStatusEvent(Throwable exception) {
super("Tor failed to start: " + (exception.getCause() != null ?
(exception.getCause().getMessage().contains("Failed to bind") ? exception.getCause().getMessage() + " Is a Tor proxy already running?" : exception.getCause().getMessage() ) :
exception.getMessage()));
super("Tor failed to start: " + (exception instanceof TorServerAlreadyBoundException ? exception.getCause().getMessage() + " Is a Tor proxy already running?" :
(exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage())));
this.exception = exception;
}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionReferencesEvent extends PagedEvent {
private final Transaction transaction;
public TransactionReferencesEvent(Transaction transaction) {
this(transaction, 0, 0);
}
public TransactionReferencesEvent(Transaction transaction, int pageStart, int pageEnd) {
super(pageStart, pageEnd);
this.transaction = transaction;
}
public Transaction getTransaction() {
return transaction;
}
}
@@ -0,0 +1,21 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionReferencesFailedEvent extends TransactionReferencesEvent {
private final Throwable exception;
public TransactionReferencesFailedEvent(Transaction transaction, Throwable exception) {
super(transaction);
this.exception = exception;
}
public TransactionReferencesFailedEvent(Transaction transaction, Throwable exception, int pageStart, int pageEnd) {
super(transaction, pageStart, pageEnd);
this.exception = exception;
}
public Throwable getException() {
return exception;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
public class TransactionReferencesFinishedEvent extends TransactionReferencesEvent {
private final BlockTransaction blockTransaction;
public TransactionReferencesFinishedEvent(Transaction transaction, BlockTransaction blockTransaction) {
super(transaction);
this.blockTransaction = blockTransaction;
}
public TransactionReferencesFinishedEvent(Transaction transaction, BlockTransaction blockTransaction, int pageStart, int pageEnd) {
super(transaction, pageStart, pageEnd);
this.blockTransaction = blockTransaction;
}
public BlockTransaction getBlockTransaction() {
return blockTransaction;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionReferencesStartedEvent extends TransactionReferencesEvent {
public TransactionReferencesStartedEvent(Transaction transaction) {
super(transaction);
}
public TransactionReferencesStartedEvent(Transaction transaction, int pageStart, int pageEnd) {
super(transaction, pageStart, pageEnd);
}
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
/**
* This event is posted when a wallet's addresses are changed (keystores, policy, script type).
* This event marks a fundamental change that is used to update application level UI, clear node entry caches and similar. It should only be subscribed to by application-level classes.
* Note that WalletForm does not listen to this event to save the wallet, since the wallet is foreground saved directly in SettingsController before this event is posted.
* This is because any failure in saving the wallet must be immediately reported to the user.
* Note that all wallet detail controllers that share a WalletForm, and that class posts WalletNodesChangedEvent once it has cleared it's entry caches.
*/
public class WalletAddressesChangedEvent extends WalletSettingsChangedEvent {
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
super(wallet, pastWallet, walletFile);
}
}
@@ -0,0 +1,16 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryFailedEvent extends WalletHistoryStatusEvent {
private final Throwable exception;
public WalletHistoryFailedEvent(Wallet wallet, Throwable exception) {
super(wallet, exception.getCause() == null ? exception.getMessage() : exception.getCause().getMessage());
this.exception = exception;
}
public Throwable getException() {
return exception;
}
}
@@ -6,8 +6,4 @@ public class WalletHistoryFinishedEvent extends WalletHistoryStatusEvent {
public WalletHistoryFinishedEvent(Wallet wallet) {
super(wallet, false);
}
public WalletHistoryFinishedEvent(Wallet wallet, String errorMessage) {
super(wallet, errorMessage);
}
}
@@ -1,9 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
public class WalletHistoryStartedEvent extends WalletHistoryStatusEvent {
public WalletHistoryStartedEvent(Wallet wallet) {
private final WalletNode walletNode;
public WalletHistoryStartedEvent(Wallet wallet, WalletNode walletNode) {
super(wallet, true);
this.walletNode = walletNode;
}
public WalletNode getWalletNode() {
return walletNode;
}
}
@@ -5,11 +5,9 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
/**
* This event is posted when a wallet's settings are changed (keystores, policy, script type).
* This event marks a fundamental change that is used to update application level UI, clear node entry caches and similar. It should only be subscribed to by application-level classes.
* Note that WalletForm does not listen to this event to save the wallet, since the wallet is foreground saved directly in SettingsController before this event is posted.
* This is because any failure in saving the wallet must be immediately reported to the user.
* Note that all wallet detail controllers that share a WalletForm, and that class posts WalletNodesChangedEvent once it has cleared it's entry caches.
* This event is posted when a wallet's settings are changed in a way that does not update the wallet addresses or history.
* For example, changing the keystore source or label will trigger this event, which updates the wallet but avoids a full wallet refresh.
* It is impossible for the validity of a wallet to change on this event - listen for WalletAddressesChangedEvent for this
*/
public class WalletSettingsChangedEvent extends WalletChangedEvent {
private final Wallet pastWallet;
@@ -7,6 +7,8 @@ import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
@@ -14,6 +16,8 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
private static final Logger log = LoggerFactory.getLogger(CoboVaultSinglesig.class);
@Override
public String getName() {
return "Cobo Vault";
@@ -53,7 +57,8 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
return keystore;
} catch (Exception e) {
throw new ImportException(e);
log.error("Error getting Cobo Vault keystore", e);
throw new ImportException("Error getting Cobo Vault keystore", e);
}
}
@@ -11,6 +11,8 @@ 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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
@@ -19,6 +21,8 @@ import java.util.List;
import java.util.Set;
public class ColdcardMultisig implements WalletImport, KeystoreFileImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(ColdcardMultisig.class);
@Override
public String getName() {
return "Coldcard Multisig";
@@ -81,7 +85,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
@Override
public String getExportFileExtension() {
public String getExportFileExtension(Wallet wallet) {
return "txt";
}
@@ -146,7 +150,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
return wallet;
} catch(Exception e) {
throw new ImportException(e);
log.error("Error importing Coldcard multisig wallet", e);
throw new ImportException("Error importing Coldcard multisig wallet", e);
}
}
@@ -199,7 +204,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
writer.flush();
writer.close();
} catch(Exception e) {
throw new ExportException(e);
log.error("Error exporting Coldcard multisig wallet", e);
throw new ExportException("Error exporting Coldcard multisig wallet", e);
}
}
@@ -227,4 +233,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
}
@@ -9,6 +9,8 @@ import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
@@ -18,6 +20,8 @@ import java.nio.charset.StandardCharsets;
import java.util.Map;
public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
private static final Logger log = LoggerFactory.getLogger(ColdcardSinglesig.class);
@Override
public String getName() {
return "Coldcard";
@@ -82,7 +86,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
}
} catch (Exception e) {
throw new ImportException(e);
log.error("Error getting Coldcard keystore", e);
throw new ImportException("Error getting Coldcard keystore", e);
}
throw new ImportException("Correct derivation not found for script type: " + scriptType);
@@ -25,6 +25,7 @@ public class Config {
private FeeRatesSelection feeRatesSelection;
private Currency fiatCurrency;
private ExchangeSource exchangeSource;
private boolean loadRecentWallets = true;
private boolean validateDerivationPaths = true;
private boolean groupByAddress = true;
private boolean includeMempoolChange = true;
@@ -37,7 +38,7 @@ public class Config {
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private File hwi;
private boolean hdCapture;
private Boolean hdCapture;
private ServerType serverType;
private String publicElectrumServer;
private String coreServer;
@@ -155,6 +156,15 @@ public class Config {
flush();
}
public boolean isLoadRecentWallets() {
return loadRecentWallets;
}
public void setLoadRecentWallets(boolean loadRecentWallets) {
this.loadRecentWallets = loadRecentWallets;
flush();
}
public boolean isValidateDerivationPaths() {
return validateDerivationPaths;
}
@@ -263,11 +273,15 @@ public class Config {
flush();
}
public boolean isHdCapture() {
public Boolean getHdCapture() {
return hdCapture;
}
public void setHdCapture(boolean hdCapture) {
public Boolean isHdCapture() {
return hdCapture != null && hdCapture;
}
public void setHdCapture(Boolean hdCapture) {
this.hdCapture = hdCapture;
flush();
}
@@ -289,8 +303,16 @@ public class Config {
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : (getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? getPublicElectrumServer() : getElectrumServer());
}
public boolean requiresInternalTor() {
if(isUseProxy()) {
return false;
}
return requiresTor();
}
public boolean requiresTor() {
if(isUseProxy() || !hasServerAddress()) {
if(!hasServerAddress()) {
return false;
}
@@ -14,6 +14,8 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
@@ -22,6 +24,8 @@ import java.util.*;
import java.util.zip.InflaterInputStream;
public class Electrum implements KeystoreFileImport, WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(Electrum.class);
@Override
public String getName() {
return "Electrum";
@@ -246,7 +250,8 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
return wallet;
} catch (Exception e) {
throw new ImportException(e);
log.error("Error importing Electrum Wallet", e);
throw new ImportException("Error importing Electrum Wallet", e);
}
}
@@ -273,7 +278,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
@Override
public String getExportFileExtension() {
public String getExportFileExtension(Wallet wallet) {
return "json";
}
@@ -350,7 +355,8 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
outputStream.flush();
outputStream.close();
} catch (Exception e) {
throw new ExportException(e);
log.error("Error exporting Electrum Wallet", e);
throw new ExportException("Error exporting Electrum Wallet", e);
}
}
@@ -379,6 +385,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
return "Export this wallet as an Electrum wallet file.";
}
@Override
public boolean walletExportRequiresDecryption() {
return true;
}
private static class ElectrumJsonWallet {
public Map<String, ElectrumKeystore> keystores = new LinkedHashMap<>();
public String wallet_type;
@@ -30,7 +30,7 @@ import java.util.zip.ZipInputStream;
public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String VERSION_PREFIX = "hwi-2.0.0-rc.2";
private static final String VERSION_PREFIX = "hwi-2.0.1";
private static boolean isPromptActive = false;
@@ -51,6 +51,7 @@ public class Hwi {
}
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
log.error("Error executing " + VERSION_PREFIX, e);
throw new ImportException(e);
} finally {
isPromptActive = false;
@@ -77,6 +78,16 @@ public class Hwi {
}
}
public boolean togglePassphrase(Device device) throws ImportException {
try {
String output = execute(getDeviceCommand(device, Command.TOGGLE_PASSPHRASE));
isPromptActive = false;
return wasSuccessful(output);
} catch(IOException e) {
throw new ImportException(e);
}
}
public String getXpub(Device device, String passphrase, String derivationPath) throws ImportException {
try {
String output;
@@ -109,8 +120,7 @@ public class Hwi {
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only single hash types supported");
}
//Remove replace once HWI-2.0.0 is released - see https://github.com/bitcoin-core/HWI/pull/488
String descriptor = outputDescriptor.toString().replace("sortedmulti", "multi");
String descriptor = outputDescriptor.toString();
isPromptActive = true;
String output;
@@ -468,6 +478,24 @@ public class Hwi {
}
}
public static class TogglePassphraseService extends Service<Boolean> {
private final Device device;
public TogglePassphraseService(Device device) {
this.device = device;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws ImportException {
Hwi hwi = new Hwi();
return hwi.togglePassphrase(device);
}
};
}
}
public static class DisplayAddressService extends Service<String> {
private final Device device;
private final String passphrase;
@@ -595,6 +623,7 @@ public class Hwi {
ENUMERATE("enumerate", true),
PROMPT_PIN("promptpin", true),
SEND_PIN("sendpin", false),
TOGGLE_PASSPHRASE("togglepassphrase", true),
DISPLAY_ADDRESS("displayaddress", true),
SIGN_MESSAGE("signmessage", true),
GET_XPUB("getxpub", true),
@@ -28,11 +28,21 @@ public class PassportMultisig extends ColdcardMultisig {
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created from Settings > Pair Software Wallet > Sparrow > Multisig > microSD/QR on your Passport.";
return "Import file or QR created from New Account > Sparrow > Multisig > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public String getWalletExportDescription() {
return "As part of the New Account > Sparrow > Multisig flow, Passport will ask you to scan or import the multisig configuration from Sparrow.";
}
@Override
public boolean isWalletExportScannable() {
return true;
}
}
@@ -14,7 +14,7 @@ public class PassportSinglesig extends ColdcardSinglesig {
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created from Settings > Pair Software Wallet > Sparrow > Single Sig > microSD/QR on your Passport.";
return "Import file or QR created from New Account > Sparrow > Standard > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
}
@Override
@@ -0,0 +1,62 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.OutputStream;
import java.nio.file.Files;
public class Sparrow implements WalletExport {
private static final Logger log = LoggerFactory.getLogger(Sparrow.class);
@Override
public String getName() {
return "Sparrow";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SPARROW;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Files.copy(storage.getWalletFile().toPath(), outputStream);
outputStream.flush();
outputStream.close();
} catch(Exception e) {
log.error("Error exporting Sparrow wallet file", e);
throw new ExportException("Error exporting Sparrow wallet file", e);
}
}
@Override
public String getWalletExportDescription() {
return "Exports a copy of your Sparrow wallet file, which can be loaded in another Sparrow instance running on any supported platform.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(storage != null && (storage.getEncryptionPubKey() == null || Storage.NO_PASSWORD_KEY.equals(storage.getEncryptionPubKey()))) {
return "json";
}
return "";
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
}
@@ -7,6 +7,8 @@ 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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
@@ -15,6 +17,8 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class SpecterDIY implements KeystoreFileImport {
private static final Logger log = LoggerFactory.getLogger(SpecterDIY.class);
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
@@ -34,7 +38,8 @@ public class SpecterDIY implements KeystoreFileImport {
return keystore;
} catch(IOException e) {
throw new ImportException(e);
log.error("Error getting Specter DIY keystore", e);
throw new ImportException("Error getting Specter DIY keystore", e);
}
}
@@ -7,6 +7,8 @@ import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
@@ -15,6 +17,8 @@ import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class SpecterDesktop implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(SpecterDesktop.class);
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
@@ -29,7 +33,8 @@ public class SpecterDesktop implements WalletImport, WalletExport {
outputStream.flush();
outputStream.close();
} catch(Exception e) {
throw new ExportException(e);
log.error("Error exporting Specter Desktop wallet", e);
throw new ExportException("Error exporting Specter Desktop wallet", e);
}
}
@@ -39,7 +44,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
}
@Override
public String getExportFileExtension() {
public String getExportFileExtension(Wallet wallet) {
return "json";
}
@@ -68,7 +73,8 @@ public class SpecterDesktop implements WalletImport, WalletExport {
return wallet;
}
} catch(Exception e) {
throw new ImportException(e);
log.error("Error importing Specter Desktop wallet", e);
throw new ImportException("Error importing Specter Desktop wallet", e);
}
throw new ImportException("File was not a valid Specter Desktop wallet");
@@ -99,6 +105,11 @@ public class SpecterDesktop implements WalletImport, WalletExport {
return WalletModel.SPECTER_DESKTOP;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
public static class SpecterWallet {
public String label;
public Integer blockheight;
@@ -24,6 +24,8 @@ import java.lang.reflect.Type;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
@@ -44,6 +46,7 @@ public class Storage {
public static final String WINDOWS_SPARROW_DIR = "Sparrow";
public static final String WALLETS_DIR = "wallets";
public static final String WALLETS_BACKUP_DIR = "backup";
public static final String CERTS_DIR = "certs";
public static final String HEADER_MAGIC_1 = "SPRW1";
private static final int BINARY_HEADER_LENGTH = 28;
public static final String TEMP_BACKUP_EXTENSION = "tmp";
@@ -384,6 +387,37 @@ public class Storage {
return walletsDir;
}
public static File getCertificateFile(String host) {
File certsDir = getCertsDir();
File[] certs = certsDir.listFiles((dir, name) -> name.equals(host));
if(certs.length > 0) {
return certs[0];
}
return null;
}
public static void saveCertificate(String host, Certificate cert) {
try(FileWriter writer = new FileWriter(new File(getCertsDir(), host))) {
writer.write("-----BEGIN CERTIFICATE-----\n");
writer.write(Base64.getEncoder().encodeToString(cert.getEncoded()).replaceAll("(.{64})", "$1\n"));
writer.write("\n-----END CERTIFICATE-----\n");
} catch(CertificateEncodingException e) {
log.error("Error encoding PEM certificate", e);
} catch(IOException e) {
log.error("Error writing PEM certificate", e);
}
}
static File getCertsDir() {
File certsDir = new File(getSparrowDir(), CERTS_DIR);
if(!certsDir.exists()) {
certsDir.mkdirs();
}
return certsDir;
}
static File getSparrowDir() {
if(Network.get() != Network.MAINNET) {
return new File(getSparrowHome(), Network.get().getName());
@@ -7,6 +7,7 @@ import java.io.OutputStream;
public interface WalletExport extends Export {
void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException;
String getWalletExportDescription();
String getExportFileExtension();
String getExportFileExtension(Wallet wallet);
boolean isWalletExportScannable();
boolean walletExportRequiresDecryption();
}
@@ -29,6 +29,8 @@ import java.io.*;
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class ElectrumServer {
@@ -46,6 +48,8 @@ public class ElectrumServer {
private static String bwtElectrumServer;
private static final Pattern RPC_WALLET_LOADING_PATTERN = Pattern.compile(".*\"(Wallet loading failed:[^\"]*)\".*");
private static synchronized Transport getTransport() throws ServerException {
if(transport == null) {
try {
@@ -824,11 +828,16 @@ public class ElectrumServer {
bwtStartCondition.await();
if(!bwt.isReady()) {
if(bwtStartException != null && bwtStartException.getMessage().contains("Wallet file not specified")) {
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
} else {
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
if(bwtStartException != null) {
Matcher walletLoadingMatcher = RPC_WALLET_LOADING_PATTERN.matcher(bwtStartException.getMessage());
if(bwtStartException.getMessage().contains("Wallet file not specified")) {
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
} else if(walletLoadingMatcher.matches() && walletLoadingMatcher.group(1) != null) {
throw new ServerException(walletLoadingMatcher.group(1));
}
}
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
}
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
@@ -944,7 +953,7 @@ public class ElectrumServer {
});
disconnectionService.start();
} else if(subscribe) {
EventManager.get().post(new DisconnectionEvent());
Platform.runLater(() -> EventManager.get().post(new DisconnectionEvent()));
}
}
@@ -56,7 +56,11 @@ public enum ExchangeSource {
Gson gson = new Gson();
return gson.fromJson(reader, CoinbaseRates.class);
} catch (Exception e) {
log.error("Error retrieving currency rates", e);
if(log.isDebugEnabled()) {
log.warn("Error retrieving currency rates", e);
} else {
log.warn("Error retrieving currency rates (" + e.getMessage() + ")");
}
return new CoinbaseRates();
}
}
@@ -86,8 +90,12 @@ public enum ExchangeSource {
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
return gson.fromJson(reader, CoinGeckoRates.class);
} catch (Exception e) {
log.error("Error retrieving currency rates", e);
} catch(Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving currency rates", e);
} else {
log.warn("Error retrieving currency rates (" + e.getMessage() + ")");
}
return new CoinGeckoRates();
}
}
@@ -63,6 +63,7 @@ public enum FeeRatesSource {
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream()); Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new Gson();
ThreeTierRates threeTierRates = gson.fromJson(reader, ThreeTierRates.class);
Double lastRate = null;
for(Integer blockTarget : defaultblockTargetFeeRates.keySet()) {
if(blockTarget < BLOCKS_IN_HALF_HOUR) {
blockTargetFeeRates.put(blockTarget, threeTierRates.fastestFee);
@@ -70,16 +71,27 @@ public enum FeeRatesSource {
blockTargetFeeRates.put(blockTarget, threeTierRates.halfHourFee);
} else if(blockTarget < BLOCKS_IN_TWO_HOURS || defaultblockTargetFeeRates.get(blockTarget) > threeTierRates.hourFee) {
blockTargetFeeRates.put(blockTarget, threeTierRates.hourFee);
} else if(threeTierRates.minimumFee != null && defaultblockTargetFeeRates.get(blockTarget) < threeTierRates.minimumFee) {
blockTargetFeeRates.put(blockTarget, threeTierRates.minimumFee + (threeTierRates.hourFee > threeTierRates.minimumFee ? threeTierRates.hourFee * 0.2 : 0.0));
} else {
blockTargetFeeRates.put(blockTarget, defaultblockTargetFeeRates.get(blockTarget));
}
if(lastRate != null) {
blockTargetFeeRates.put(blockTarget, Math.min(lastRate, blockTargetFeeRates.get(blockTarget)));
}
lastRate = blockTargetFeeRates.get(blockTarget);
}
if(threeTierRates.minimumFee != null) {
blockTargetFeeRates.put(Integer.MAX_VALUE, threeTierRates.minimumFee);
}
} catch (Exception e) {
log.warn("Error retrieving recommended fee rates from " + url, e);
if(log.isDebugEnabled()) {
log.warn("Error retrieving recommended fee rates from " + url, e);
} else {
log.warn("Error retrieving recommended fee rates from " + url + " (" + e.getMessage() + ")");
}
}
return blockTargetFeeRates;
@@ -39,7 +39,7 @@ public enum Protocol {
},
SSL {
@Override
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
public Transport getTransport(HostAndPort server) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
if(isOnionAddress(server)) {
return new TorTcpOverTlsTransport(server);
}
@@ -57,7 +57,7 @@ public enum Protocol {
}
@Override
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws NoSuchAlgorithmException, KeyManagementException {
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
return new ProxyTcpOverTlsTransport(server, proxy);
}
@@ -68,27 +68,27 @@ public enum Protocol {
},
HTTP {
@Override
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
public Transport getTransport(HostAndPort server) {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
@Override
public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
public Transport getTransport(HostAndPort server, File serverCert) {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
@Override
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
public Transport getTransport(HostAndPort server, HostAndPort proxy) {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
@Override
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
};
public abstract Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException;
public abstract Transport getTransport(HostAndPort server) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
public abstract Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException;
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.net;
public class ProxyServerException extends ServerException {
public ProxyServerException(Throwable cause) {
super(cause);
}
}
@@ -18,7 +18,7 @@ public class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
private final HostAndPort proxy;
public ProxyTcpOverTlsTransport(HostAndPort server, HostAndPort proxy) throws KeyManagementException, NoSuchAlgorithmException {
public ProxyTcpOverTlsTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
super(server);
this.proxy = proxy;
}
@@ -34,7 +34,7 @@ public class ProxyTcpOverTlsTransport extends TcpOverTlsTransport {
Socket underlying = new Socket(new Proxy(Proxy.Type.SOCKS, proxyAddr));
underlying.connect(new InetSocketAddress(server.getHost(), server.getPortOrDefault(DEFAULT_PORT)));
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(underlying, proxy.getHost(), proxy.getPortOrDefault(DEFAULT_PROXY_PORT), true);
sslSocket.startHandshake();
startHandshake(sslSocket);
return sslSocket;
}
@@ -145,6 +145,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
String blockHeader = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.block.header").id(idCounter.incrementAndGet()).params(blockHeight).execute());
result.put(blockHeight, blockHeader);
} catch(ServerException e) {
//If there is an error with the server connection, don't keep trying - this may take too long given many blocks
throw new ElectrumServerRpcException("Failed to retrieve block header for block height: " + blockHeight, e);
} catch(JsonRpcException e) {
log.warn("Failed to retrieve block header for block height: " + blockHeight + " (" + e.getErrorMessage() + ")");
} catch(Exception e) {
@@ -1,6 +1,9 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.io.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.*;
import java.io.File;
@@ -8,35 +11,23 @@ import java.io.FileInputStream;
import java.io.IOException;
import java.net.Socket;
import java.security.*;
import java.security.cert.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
public class TcpOverTlsTransport extends TcpTransport {
private static final Logger log = LoggerFactory.getLogger(TcpOverTlsTransport.class);
public static final int DEFAULT_PORT = 50002;
protected final SSLSocketFactory sslSocketFactory;
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException {
public TcpOverTlsTransport(HostAndPort server) throws NoSuchAlgorithmException, KeyManagementException, CertificateException, KeyStoreException, IOException {
super(server);
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) {
}
}
};
TrustManager[] trustManagers = getTrustManagers(Storage.getCertificateFile(server.getHost()));
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new SecureRandom());
sslContext.init(null, trustManagers, new SecureRandom());
this.sslSocketFactory = sslContext.getSocketFactory();
}
@@ -44,25 +35,77 @@ public class TcpOverTlsTransport extends TcpTransport {
public TcpOverTlsTransport(HostAndPort server, File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
super(server);
Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
keyStore.setCertificateEntry("electrumx", certificate);
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);
TrustManager[] trustManagers = getTrustManagers(crtFile);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagerFactory.getTrustManagers(), null);
sslContext.init(null, trustManagers, null);
sslSocketFactory = sslContext.getSocketFactory();
}
private TrustManager[] getTrustManagers(File crtFile) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException {
if(crtFile == null) {
return new TrustManager[] {
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException {
}
public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException {
if(certs.length == 0) {
throw new CertificateException("No server certificate provided");
}
certs[0].checkValidity();
}
}
};
}
Certificate certificate = CertificateFactory.getInstance("X.509").generateCertificate(new FileInputStream(crtFile));
if(certificate instanceof X509Certificate) {
try {
X509Certificate x509Certificate = (X509Certificate)certificate;
x509Certificate.checkValidity();
} catch(CertificateException e) {
crtFile.delete();
return getTrustManagers(null);
}
}
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
keyStore.setCertificateEntry("electrum-server", certificate);
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);
return trustManagerFactory.getTrustManagers();
}
protected Socket createSocket() throws IOException {
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(server.getHost(), server.getPortOrDefault(DEFAULT_PORT));
sslSocket.startHandshake();
startHandshake(sslSocket);
return sslSocket;
}
protected void startHandshake(SSLSocket sslSocket) throws IOException {
sslSocket.addHandshakeCompletedListener(event -> {
if(Storage.getCertificateFile(server.getHost()) == null) {
try {
Certificate[] certs = event.getPeerCertificates();
if(certs.length > 0) {
Storage.saveCertificate(server.getHost(), certs[0]);
}
} catch(SSLPeerUnverifiedException e) {
log.warn("Attempting to retrieve certificate for unverified peer", e);
}
}
});
sslSocket.startHandshake();
}
}
@@ -10,6 +10,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLHandshakeException;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
@@ -192,7 +193,13 @@ public class TcpTransport implements Transport, Closeable {
try {
socket = createSocket();
running = true;
} catch(SSLHandshakeException e) {
throw new TlsServerException(server, e);
} catch(IOException e) {
if(e.getStackTrace().length > 0 && e.getStackTrace()[0].getClassName().contains("SocksSocketImpl")) {
throw new ProxyServerException(e);
}
throw new ServerException(e);
}
}
@@ -0,0 +1,59 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import java.io.File;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
public class TlsServerException extends ServerException {
private final HostAndPort server;
public TlsServerException(HostAndPort server) {
this.server = server;
}
public TlsServerException(HostAndPort server, String message) {
super(message);
this.server = server;
}
public TlsServerException(HostAndPort server, Throwable cause) {
this(server, getMessage(cause, server), cause);
}
public TlsServerException(HostAndPort server, String message, Throwable cause) {
super(message, cause);
this.server = server;
}
public HostAndPort getServer() {
return server;
}
private static String getMessage(Throwable cause, HostAndPort server) {
if(cause != null) {
if(cause.getMessage().contains("PKIX path building failed")) {
File configCrtFile = Config.get().getElectrumServerCert();
File savedCrtFile = Storage.getCertificateFile(server.getHost());
if(configCrtFile != null) {
return "Provided server certificate from " + server.getHost() + " did not match configured certificate at " + configCrtFile.getAbsolutePath();
} else if(savedCrtFile != null) {
return "Provided server certificate from " + server.getHost() + " did not match previously saved certificate at " + savedCrtFile.getAbsolutePath();
}
return "Provided server certificate from " + server.getHost() + " was invalid: " + (cause.getCause() != null ? cause.getCause().getMessage() : cause.getMessage());
} else if(cause.getCause() instanceof CertificateNotYetValidException) {
return cause.getMessage().replace("NotBefore:", "Certificate only valid from");
} else if(cause.getCause() instanceof CertificateExpiredException) {
return cause.getMessage().replace("NotAfter:", "Certificate expired at");
}
return cause.getMessage();
}
return "SSL Handshake Error";
}
}
@@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.net;
public class TorServerAlreadyBoundException extends TorServerException {
public TorServerAlreadyBoundException(Throwable cause) {
super(cause);
}
public TorServerAlreadyBoundException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.net;
public class TorServerException extends ServerException {
public TorServerException(Throwable cause) {
super(cause);
}
public TorServerException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -32,7 +32,7 @@ public class TorService extends ScheduledService<NativeTor> {
@Override
protected Task<NativeTor> createTask() {
return new Task<>() {
protected NativeTor call() throws IOException {
protected NativeTor call() throws IOException, TorServerException {
if(Tor.getDefault() == null) {
Path path = Files.createTempDirectory(TOR_DIR_PREFIX);
File torInstallDir = path.toFile();
@@ -45,11 +45,15 @@ public class TorService extends ScheduledService<NativeTor> {
return new NativeTor(torInstallDir, Collections.emptyList(), override);
} catch(TorCtlException e) {
log.error("Failed to start Tor", e);
if(e.getCause() instanceof TorControlError) {
throw new IOException("Failed to start Tor", e.getCause());
if(e.getCause().getMessage().contains("Failed to bind")) {
throw new TorServerAlreadyBoundException("Tor server already bound", e.getCause());
}
log.error("Failed to start Tor", e);
throw new TorServerException("Failed to start Tor", e.getCause());
} else {
throw new IOException("Failed to start Tor", e);
log.error("Failed to start Tor", e);
throw new TorServerException("Failed to start Tor", e);
}
}
}
@@ -17,7 +17,7 @@ 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 {
public TorTcpOverTlsTransport(HostAndPort server) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
super(server);
}
@@ -42,7 +42,7 @@ public class TorTcpOverTlsTransport extends TcpOverTlsTransport {
}
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(socket, server.getHost(), server.getPortOrDefault(DEFAULT_PORT), true);
sslSocket.startHandshake();
startHandshake(sslSocket);
return sslSocket;
}
@@ -33,6 +33,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
@FXML
private ComboBox<ExchangeSource> exchangeSource;
@FXML
private UnlabeledToggleSwitch loadRecentWallets;
@FXML
private UnlabeledToggleSwitch validateDerivationPaths;
@@ -97,6 +100,12 @@ public class GeneralPreferencesController extends PreferencesDetailController {
updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem());
loadRecentWallets.setSelected(config.isLoadRecentWallets());
loadRecentWallets.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
config.setLoadRecentWallets(newValue);
EventManager.get().post(new RequestOpenWalletsEvent());
});
validateDerivationPaths.setSelected(config.isValidateDerivationPaths());
validateDerivationPaths.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
config.setValidateDerivationPaths(newValue);
@@ -47,7 +47,7 @@ public class PreferencesDialog extends Dialog<Boolean> {
}
dialogPane.setPrefWidth(750);
dialogPane.setPrefHeight(600);
dialogPane.setPrefHeight(630);
preferencesController.reconnectOnClosingProperty().set(AppServices.isConnecting() || AppServices.isConnected());
setOnCloseRequest(event -> {
@@ -11,6 +11,7 @@ import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
@@ -35,13 +36,13 @@ import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import tornadofx.control.Form;
import javax.net.ssl.SSLHandshakeException;
import java.io.File;
import java.io.FileInputStream;
import java.security.cert.CertificateFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Optional;
import java.util.Random;
public class ServerPreferencesController extends PreferencesDetailController {
@@ -151,6 +152,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
private Boolean useSslOriginal;
private Boolean useProxyOriginal;
@Override
public void initializeView(Config config) {
EventManager.get().register(this);
@@ -303,7 +306,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
testResults.setText("Connecting to " + config.getServerAddress() + "...");
if(Config.get().requiresTor() && Tor.getDefault() == null) {
if(Config.get().requiresInternalTor() && Tor.getDefault() == null) {
startTor();
} else {
startElectrumConnection();
@@ -424,6 +427,20 @@ public class ServerPreferencesController extends PreferencesDetailController {
torService.cancel();
testResults.appendText("\nTor failed to start");
showConnectionFailure(workerStateEvent.getSource().getException());
Throwable exception = workerStateEvent.getSource().getException();
if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER &&
exception instanceof TorServerAlreadyBoundException &&
useProxyOriginal == null && !useProxy.isSelected() &&
(proxyHost.getText().isEmpty() || proxyHost.getText().equals("localhost") || proxyHost.getText().equals("127.0.0.1")) &&
(proxyPort.getText().isEmpty() || proxyPort.getText().equals("9050"))) {
useProxy.setSelected(true);
proxyHost.setText("localhost");
proxyPort.setText("9050");
useProxyOriginal = false;
testResults.appendText("\n\nAssuming Tor proxy is running on port 9050 and trying again...");
startElectrumConnection();
}
});
torService.start();
@@ -448,6 +465,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
getMasterController().reconnectOnClosingProperty().set(true);
Config.get().setMode(Mode.ONLINE);
connectionService.cancel();
useProxyOriginal = null;
});
connectionService.setOnFailed(workerStateEvent -> {
EventManager.get().unregister(connectionService);
@@ -475,6 +493,13 @@ public class ServerPreferencesController extends PreferencesDetailController {
useSslOriginal = null;
}
}
if(useProxyOriginal != null && !useProxyOriginal) {
useProxy.setSelected(false);
proxyHost.setText("");
proxyPort.setText("");
useProxyOriginal = null;
}
});
connectionService.start();
}
@@ -523,10 +548,29 @@ public class ServerPreferencesController extends PreferencesDetailController {
private void showConnectionFailure(Throwable exception) {
log.error("Connection error", exception);
String reason = exception.getCause() != null ? exception.getCause().getMessage() : exception.getMessage();
if(exception.getCause() != null && exception.getCause() instanceof SSLHandshakeException) {
reason = "SSL Handshake Error\n" + reason;
}
if(exception.getCause() != null && exception.getCause() instanceof TorControlError && exception.getCause().getMessage().contains("Failed to bind")) {
if(exception instanceof TlsServerException && exception.getCause() != null) {
TlsServerException tlsServerException = (TlsServerException)exception;
if(exception.getCause().getMessage().contains("PKIX path building failed")) {
File configCrtFile = Config.get().getElectrumServerCert();
File savedCrtFile = Storage.getCertificateFile(tlsServerException.getServer().getHost());
if(configCrtFile == null && savedCrtFile != null) {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent()) {
if(optButton.get() == ButtonType.YES) {
savedCrtFile.delete();
Platform.runLater(this::startElectrumConnection);
return;
}
}
}
}
reason = tlsServerException.getMessage() + "\n\n" + reason;
} else if(exception instanceof ProxyServerException) {
reason += ". Check if the proxy server is running.";
} else if(exception instanceof TorServerAlreadyBoundException) {
reason += "\nIs a Tor proxy already running on port " + TorService.PROXY_PORT + "?";
}
@@ -680,10 +680,8 @@ public class HeadersController extends TransactionFormController implements Init
file = new File(file.getAbsolutePath() + ".psbt");
}
try {
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
writer.print(headersForm.getPsbt().toBase64String());
}
try(FileOutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(headersForm.getPsbt().serialize());
} catch(IOException e) {
log.error("Error saving PSBT", e);
AppServices.showErrorDialog("Error saving PSBT", "Cannot write to " + file.getAbsolutePath());
@@ -716,7 +714,7 @@ public class HeadersController extends TransactionFormController implements Init
File file = headersForm.getAvailableWallets().get(headersForm.getSigningWallet()).getWalletFile();
if(copy.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
@@ -846,10 +844,13 @@ public class HeadersController extends TransactionFormController implements Init
headersForm.setBlockTransaction(blockTransaction);
updateBlockchainForm(blockTransaction, AppServices.getCurrentBlockHeight());
}
EventManager.get().post(new TransactionReferencesFinishedEvent(headersForm.getTransaction(), blockTransaction));
});
transactionReferenceService.setOnFailed(failedEvent -> {
log.error("Error fetching broadcasted transaction", failedEvent.getSource().getException());
EventManager.get().post(new TransactionReferencesFailedEvent(headersForm.getTransaction(), failedEvent.getSource().getException()));
});
EventManager.get().post(new TransactionReferencesStartedEvent(headersForm.getTransaction()));
transactionReferenceService.start();
}
});
@@ -1132,10 +1133,13 @@ public class HeadersController extends TransactionFormController implements Init
headersForm.setBlockTransaction(blockTransaction);
updateBlockchainForm(blockTransaction, AppServices.getCurrentBlockHeight());
}
EventManager.get().post(new TransactionReferencesFinishedEvent(headersForm.getTransaction(), blockTransaction));
});
transactionReferenceService.setOnFailed(failEvent -> {
log.error("Could not update block transaction", failEvent.getSource().getException());
EventManager.get().post(new TransactionReferencesFailedEvent(headersForm.getTransaction(), failEvent.getSource().getException()));
});
EventManager.get().post(new TransactionReferencesStartedEvent(headersForm.getTransaction()));
transactionReferenceService.start();
}
}
@@ -374,12 +374,14 @@ public class TransactionController implements Initializable {
final BlockTransaction blockTx = thisBlockTx;
Platform.runLater(() -> {
EventManager.get().post(new BlockTransactionFetchedEvent(getTransaction().getTxId(), blockTx, inputTransactions, indexStart, maxIndex));
EventManager.get().post(new BlockTransactionFetchedEvent(getTransaction(), blockTx, inputTransactions, indexStart, maxIndex));
});
});
transactionReferenceService.setOnFailed(failedEvent -> {
log.error("Error fetching transaction or input references", failedEvent.getSource().getException());
EventManager.get().post(new TransactionReferencesFailedEvent(getTransaction(), failedEvent.getSource().getException(), indexStart, maxIndex));
});
EventManager.get().post(new TransactionReferencesStartedEvent(getTransaction(), indexStart, maxIndex));
transactionReferenceService.start();
}
}
@@ -391,12 +393,14 @@ public class TransactionController implements Initializable {
transactionOutputsReferenceService.setOnSucceeded(successEvent -> {
List<BlockTransaction> outputTransactions = transactionOutputsReferenceService.getValue();
Platform.runLater(() -> {
EventManager.get().post(new BlockTransactionOutputsFetchedEvent(getTransaction().getTxId(), outputTransactions, indexStart, maxIndex));
EventManager.get().post(new BlockTransactionOutputsFetchedEvent(getTransaction(), outputTransactions, indexStart, maxIndex));
});
});
transactionOutputsReferenceService.setOnFailed(failedEvent -> {
log.error("Error fetching transaction output references", failedEvent.getSource().getException());
EventManager.get().post(new TransactionReferencesFailedEvent(getTransaction(), failedEvent.getSource().getException(), indexStart, maxIndex));
});
EventManager.get().post(new TransactionReferencesStartedEvent(getTransaction(), indexStart, maxIndex));
transactionOutputsReferenceService.start();
}
}
@@ -1,19 +1,33 @@
package com.sparrowwallet.sparrow.wallet;
import com.csvreader.CsvWriter;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.AddressTreeTable;
import com.sparrowwallet.sparrow.event.*;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.ResourceBundle;
public class AddressesController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(AddressesController.class);
public static final int DEFAULT_EXPORT_ADDRESSES_LENGTH = 250;
@FXML
private AddressTreeTable receiveTable;
@@ -83,4 +97,45 @@ public class AddressesController extends WalletFormController implements Initial
changeTable.updateAll(getWalletForm().getNodeEntry(KeyPurpose.CHANGE));
}
}
public void exportReceiveAddresses(ActionEvent event) {
exportAddresses(KeyPurpose.RECEIVE);
}
public void exportChangeAddresses(ActionEvent event) {
exportAddresses(KeyPurpose.CHANGE);
}
private void exportAddresses(KeyPurpose keyPurpose) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export Addresses to CSV");
fileChooser.setInitialFileName(getWalletForm().getWallet().getName() + "-" + keyPurpose.name().toLowerCase() + "-addresses.txt");
Wallet copy = getWalletForm().getWallet().copy();
WalletNode purposeNode = copy.getNode(keyPurpose);
purposeNode.fillToIndex(Math.max(purposeNode.getChildren().size(), DEFAULT_EXPORT_ADDRESSES_LENGTH));
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8);
writer.writeRecord(new String[] {"Index", "Payment Address", "Derivation", "Label"});
for(WalletNode indexNode : purposeNode.getChildren()) {
writer.write(Integer.toString(indexNode.getIndex()));
writer.write(copy.getAddress(indexNode).toString());
writer.write(getDerivationPath(indexNode));
Optional<Entry> optLabelEntry = getWalletForm().getNodeEntry(keyPurpose).getChildren().stream()
.filter(entry -> ((NodeEntry)entry).getNode().getIndex() == indexNode.getIndex()).findFirst();
writer.write(optLabelEntry.isPresent() ? optLabelEntry.get().getLabel() : "");
writer.endRecord();
}
writer.close();
} catch(IOException e) {
log.error("Error exporting addresses as CSV", e);
AppServices.showErrorDialog("Error exporting addresses as CSV", e.getMessage());
}
}
}
}
@@ -252,12 +252,21 @@ public class KeystoreController extends WalletFormController implements Initiali
importButton.setTooltip(new Tooltip(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import a keystore from an external source" : "Replace this keystore with another source"));
boolean editable = (keystore.getSource() == KeystoreSource.SW_WATCH);
fingerprint.setEditable(editable);
derivation.setEditable(editable);
xpub.setEditable(editable);
setEditable(fingerprint, editable);
setEditable(derivation, editable);
setEditable(xpub, editable);
scanXpubQR.setVisible(editable);
}
private void setEditable(TextInputControl textInputControl, boolean editable) {
textInputControl.setEditable(editable);
if(!editable && !textInputControl.getStyleClass().contains("readonly")) {
textInputControl.getStyleClass().add("readonly");
} else if(editable) {
textInputControl.getStyleClass().remove("readonly");
}
}
private String getTypeLabel(Keystore keystore) {
switch (keystore.getSource()) {
case HW_USB:
@@ -329,7 +338,7 @@ public class KeystoreController extends WalletFormController implements Initiali
Wallet copy = getWalletForm().getWallet().copy();
if(copy.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
@@ -117,20 +117,7 @@ public class ReceiveController extends WalletFormController implements Initializ
}
private void updateDerivationPath(NodeEntry nodeEntry) {
KeyDerivation firstDerivation = getWalletForm().getWallet().getKeystores().get(0).getKeyDerivation();
boolean singleDerivationPath = true;
for(Keystore keystore : getWalletForm().getWallet().getKeystores()) {
if(!keystore.getKeyDerivation().getDerivationPath().equals(firstDerivation.getDerivationPath())) {
singleDerivationPath = false;
break;
}
}
if(singleDerivationPath) {
derivationPath.setText(firstDerivation.extend(nodeEntry.getNode().getDerivation()).getDerivationPath());
} else {
derivationPath.setText(nodeEntry.getNode().getDerivationPath().replace("m", "multi"));
}
derivationPath.setText(getDerivationPath(nodeEntry.getNode()));
}
private void updateLastUsed() {
@@ -258,7 +245,7 @@ public class ReceiveController extends WalletFormController implements Initializ
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
displayAddress.setUserData(null);
}
@@ -50,7 +50,7 @@ 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 List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
@@ -644,7 +644,7 @@ public class SendController extends WalletFormController implements Initializabl
Integer targetBlocks = getTargetBlocks(feeRateAmt);
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
if(minFeeRate > 1.0 && feeRateAmt <= minFeeRate) {
if(minFeeRate > 1.0 && feeRateAmt < minFeeRate) {
feeRatePriority.setText("Below Minimum");
feeRatePriority.setTooltip(new Tooltip("Transactions at this fee rate are currently being purged from the default sized mempool"));
feeRatePriorityGlyph.setStyle("-fx-text-fill: #a0a1a7cc");
@@ -653,7 +653,7 @@ public class SendController extends WalletFormController implements Initializabl
}
Double lowestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(TARGET_BLOCKS_RANGE.size() - 1));
if(lowestBlocksRate > minFeeRate && feeRateAmt < (minFeeRate + ((lowestBlocksRate - minFeeRate) / 2))) {
if(lowestBlocksRate >= minFeeRate && feeRateAmt < (minFeeRate + ((lowestBlocksRate - minFeeRate) / 2)) && !isPayjoinTx()) {
feeRatePriority.setText("Try Then Replace");
feeRatePriority.setTooltip(new Tooltip("Send a transaction, verify it appears in the destination wallet, then RBF to get it confirmed or sent to another address"));
feeRatePriorityGlyph.setStyle("-fx-text-fill: #7eb7c9cc");
@@ -691,6 +691,14 @@ public class SendController extends WalletFormController implements Initializabl
}
}
private boolean isPayjoinTx() {
if(walletTransactionProperty.get() != null) {
return walletTransactionProperty.get().getPayments().stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
}
return false;
}
private Node getSliderThumb() {
return targetBlocks.lookup(".thumb");
}
@@ -921,6 +929,8 @@ public class SendController extends WalletFormController implements Initializabl
mempoolSizeFeeRatesChart.update(getMempoolHistogram());
if(targetBlocksField.isVisible()) {
setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
} else {
setFeeRatePriority(getFeeRangeRate());
}
addFeeRangeTrackHighlight(0);
}
@@ -65,6 +65,8 @@ public class SettingsController extends WalletFormController implements Initiali
private TabPane keystoreTabs;
@FXML Button export;
@FXML
private Button apply;
@@ -221,6 +223,7 @@ public class SettingsController extends WalletFormController implements Initiali
scriptType.getSelectionModel().select(walletForm.getWallet().getScriptType());
}
export.setDisable(!walletForm.getWallet().isValid());
revert.setDisable(true);
apply.setDisable(true);
}
@@ -276,7 +279,7 @@ public class SettingsController extends WalletFormController implements Initiali
TextAreaDialog dialog = new TextAreaDialog(outputDescriptorString);
dialog.setTitle("Edit wallet output descriptor");
dialog.getDialogPane().setHeaderText("Wallet output descriptor:");
dialog.getDialogPane().setHeaderText("The wallet configuration is specified in the output descriptor.\nChanges to the output descriptor will modify the wallet configuration.");
Optional<String> text = dialog.showAndWait();
if(text.isPresent() && !text.get().isEmpty() && !text.get().equals(outputDescriptorString)) {
setDescriptorText(text.get());
@@ -312,6 +315,20 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
public void exportWallet(ActionEvent event) {
if(walletForm.getWalletFile() == null) {
throw new IllegalStateException("Cannot export unsaved wallet");
}
Optional<Wallet> optWallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> walletForm.getWalletFile().equals(entry.getValue().getWalletFile())).map(Map.Entry::getKey).findFirst();
if(optWallet.isPresent()) {
WalletExportDialog dlg = new WalletExportDialog(optWallet.get());
dlg.showAndWait();
} else {
AppServices.showErrorDialog("Cannot export wallet", "Wallet cannot be exported, please save it first.");
}
}
@Override
protected String describeKeystore(Keystore keystore) {
if(!keystore.isValid()) {
@@ -341,11 +358,19 @@ public class SettingsController extends WalletFormController implements Initiali
revert.setDisable(false);
apply.setDisable(!wallet.isValid());
export.setDisable(true);
}
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
if(event.getWalletFile().equals(walletForm.getWalletFile())) {
export.setDisable(!event.getWallet().isValid());
}
}
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
updateBirthDate(event.getWalletFile(), event.getWallet());
}
@@ -404,9 +429,7 @@ public class SettingsController extends WalletFormController implements Initiali
try {
walletForm.getStorage().setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
walletForm.saveAndRefresh();
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW || requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_CHANGE) {
EventManager.get().post(new RequestOpenWalletsEvent());
}
EventManager.get().post(new RequestOpenWalletsEvent());
} catch (IOException e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet", e.getMessage());
@@ -450,10 +473,7 @@ public class SettingsController extends WalletFormController implements Initiali
walletForm.getWallet().encrypt(key);
walletForm.getStorage().setEncryptionPubKey(encryptionPubKey);
walletForm.saveAndRefresh();
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW || requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_EMPTY) {
EventManager.get().post(new RequestOpenWalletsEvent());
}
EventManager.get().post(new RequestOpenWalletsEvent());
} catch (Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet", e.getMessage());
@@ -1,8 +1,10 @@
package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.io.Storage;
@@ -52,6 +54,8 @@ public class SettingsWalletForm extends WalletForm {
save();
if(refreshAll) {
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, getWalletFile()));
} else {
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, getWalletFile()));
}
}
@@ -91,6 +95,10 @@ public class SettingsWalletForm extends WalletForm {
//TODO: Determine if Miniscript has changed for custom policies
if(!Objects.equals(getNumSignaturesRequired(original.getDefaultPolicy()), getNumSignaturesRequired(changed.getDefaultPolicy()))) {
return true;
}
if(original.getKeystores().size() != changed.getKeystores().size()) {
return true;
}
@@ -110,4 +118,8 @@ public class SettingsWalletForm extends WalletForm {
return false;
}
private Integer getNumSignaturesRequired(Policy policy) {
return policy == null ? null : policy.getNumSignaturesRequired();
}
}
@@ -5,7 +5,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ReceiveActionEvent;
import com.sparrowwallet.sparrow.event.SendActionEvent;
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import javafx.application.Platform;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
@@ -106,7 +106,7 @@ public class WalletController extends WalletFormController implements Initializa
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
if(event.getWalletFile().equals(walletForm.getWalletFile())) {
configure(event.getWallet().isValid());
}
@@ -105,14 +105,10 @@ public class WalletForm {
});
historyService.setOnFailed(workerStateEvent -> {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
EventManager.get().post(new WalletHistoryFinishedEvent(wallet, workerStateEvent.getSource().getException().getMessage()));
EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
});
if(node == null && wallet.getTransactions().isEmpty()) {
EventManager.get().post(new WalletHistoryStartedEvent(wallet));
} else {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true));
}
EventManager.get().post(new WalletHistoryStartedEvent(wallet, node));
historyService.start();
}
}
@@ -269,17 +265,20 @@ public class WalletForm {
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
if(event.getWalletFile().equals(storage.getWalletFile())) {
wallet = event.getWallet();
walletTransactionsEntry = null;
walletUtxosEntry = null;
accountEntries.clear();
EventManager.get().post(new WalletNodesChangedEvent(wallet));
//It is necessary to save the past wallet because the actual copying of the past labels only occurs on a later ConnectionEvent with bwt
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
savedPastWallet = event.getPastWallet();
if(event instanceof WalletAddressesChangedEvent) {
walletTransactionsEntry = null;
walletUtxosEntry = null;
accountEntries.clear();
EventManager.get().post(new WalletNodesChangedEvent(wallet));
//It is necessary to save the past wallet because the actual copying of the past labels only occurs on a later ConnectionEvent with bwt
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
savedPastWallet = event.getPastWallet();
}
refreshHistory(AppServices.getCurrentBlockHeight(), event.getPastWallet());
}
refreshHistory(AppServices.getCurrentBlockHeight(), event.getPastWallet());
}
}
@@ -1,6 +1,9 @@
package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.BaseController;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
@@ -30,4 +33,24 @@ public abstract class WalletFormController extends BaseController {
}
}
}
protected boolean isSingleDerivationPath() {
KeyDerivation firstDerivation = getWalletForm().getWallet().getKeystores().get(0).getKeyDerivation();
for(Keystore keystore : getWalletForm().getWallet().getKeystores()) {
if(!keystore.getKeyDerivation().getDerivationPath().equals(firstDerivation.getDerivationPath())) {
return false;
}
}
return true;
}
protected String getDerivationPath(WalletNode node) {
if(isSingleDerivationPath()) {
KeyDerivation firstDerivation = getWalletForm().getWallet().getKeystores().get(0).getKeyDerivation();
return firstDerivation.extend(node.getDerivation()).getDerivationPath();
}
return node.getDerivationPath().replace("m", "multi");
}
}
+2
View File
@@ -28,4 +28,6 @@ open module com.sparrowwallet.sparrow {
requires bwt.jni;
requires jtorctl;
requires javacsv;
requires jul.to.slf4j;
requires bridj;
}
@@ -20,6 +20,10 @@
-fx-fill: #383a42;
}
.tab-label .failure {
-fx-text-fill: rgb(202, 18, 67);
}
.status-bar .status-label {
-fx-alignment: center-left;
}
@@ -30,6 +34,15 @@
-fx-spacing: 10;
}
.version-hyperlink {
-fx-border-color: transparent;
-fx-text-fill: #1e88cf;
}
.version-hyperlink:visited {
-fx-underline: false;
}
.core-server.toggle-switch:selected .thumb-area {
-fx-background-color: linear-gradient(to bottom, derive(-fx-text-box-border, -20%), derive(-fx-text-box-border, -30%)), linear-gradient(to bottom, derive(#50a14f, 30%), #50a14f);
-fx-background-insets: 0, 1;
@@ -27,6 +27,10 @@
</items>
</Menu>
<MenuItem fx:id="saveTransaction" mnemonicParsing="false" text="Save Transaction..." accelerator="Shortcut+S" onAction="#saveTransaction"/>
<Menu fx:id="savePSBT" mnemonicParsing="false" text="Save PSBT">
<MenuItem text="As Binary..." onAction="#savePSBTBinary" accelerator="Shortcut+S"/>
<MenuItem text="As Base64..." onAction="#savePSBTText"/>
</Menu>
<SeparatorMenuItem />
<MenuItem mnemonicParsing="false" text="Import Wallet..." onAction="#importWallet"/>
<MenuItem fx:id="exportWallet" mnemonicParsing="false" text="Export Wallet..." onAction="#exportWallet"/>
@@ -43,7 +47,7 @@
<fx:define>
<ToggleGroup fx:id="theme"/>
</fx:define>
<Menu mnemonicParsing="false" text="View">
<Menu fx:id="viewMenu" mnemonicParsing="false" text="View">
<items>
<Menu mnemonicParsing="false" text="Bitcoin Unit">
<items>
@@ -81,13 +85,16 @@
<SeparatorMenuItem />
<CheckMenuItem fx:id="openWalletsInNewWindows" mnemonicParsing="false" text="Open Wallets in New Windows" onAction="#openWalletsInNewWindows"/>
<CheckMenuItem fx:id="hideEmptyUsedAddresses" mnemonicParsing="false" text="Hide Empty Used Addresses" onAction="#hideEmptyUsedAddresses"/>
<CheckMenuItem fx:id="useHdCameraResolution" mnemonicParsing="false" text="Use HD Camera Resolution" onAction="#useHdCameraResolution"/>
<CheckMenuItem fx:id="showTxHex" mnemonicParsing="false" text="Show Transaction Hex" onAction="#showTxHex"/>
<SeparatorMenuItem />
<MenuItem fx:id="minimizeToTray" mnemonicParsing="false" text="Minimize to System Tray" accelerator="Shortcut+Y" onAction="#minimizeToTray"/>
<MenuItem fx:id="refreshWallet" mnemonicParsing="false" text="Refresh Wallet" accelerator="Shortcut+R" onAction="#refreshWallet"/>
</items>
</Menu>
<Menu fx:id="toolsMenu" mnemonicParsing="false" text="Tools">
<MenuItem mnemonicParsing="false" text="Sign/Verify Message" accelerator="Shortcut+M" onAction="#signVerifyMessage"/>
<MenuItem styleClass="osxHide,windowsHide" mnemonicParsing="false" text="Install Udev Rules" onAction="#installUdevRules"/>
</Menu>
<Menu fx:id="helpMenu" mnemonicParsing="false" text="Help">
<MenuItem mnemonicParsing="false" text="Show Introduction" onAction="#showIntroduction"/>
@@ -82,6 +82,10 @@
-fx-bar-fill: rgba(135, 138, 149, 0.5);
}
.tab-label .failure {
-fx-fill: #e06c75;
}
.root .titled-description-pane .status-error .text, .root .titled-description-pane .description-error .text {
-fx-fill: #e06c75;
}
@@ -148,6 +152,14 @@
color-grey: #3e4451;
}
.root .readonly.text-input {
-fx-text-fill: lightgray;
}
.root .descriptor-text {
-fx-fill: lightgray;
}
.root .success {
-fx-text-fill: #98c379;
}
@@ -1,3 +1,3 @@
.descriptor-text { -fx-fill: -fx-text-inner-color }
.descriptor-error { -fx-fill: #ca1243 }
.descriptor-text { -fx-fill: derive(-fx-text-inner-color, 40%) }
.descriptor-error { -fx-fill: rgba(202, 18, 67, 0.8) }
@@ -128,6 +128,10 @@
-fx-background-color: #116a8d;
}
.readonly.text-input {
-fx-text-fill: derive(-fx-text-inner-color, 40%);
}
.help-label {
-fx-padding: 0 0 0 10;
}
@@ -167,4 +171,16 @@
.alert .content.label {
-fx-padding: 20px 20px 20px 20px;
}
.icon-button {
-fx-border-style: none;
-fx-border-width: 0;
-fx-border-insets: 0;
-fx-background-color: transparent;
-fx-opacity: 0.7;
}
.icon-button:hover {
-fx-opacity: 1.0;
}
@@ -72,6 +72,10 @@
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Wallet" styleClass="wideLabelFieldSet">
<Field text="Load recent wallets:">
<UnlabeledToggleSwitch fx:id="loadRecentWallets" />
<HelpLabel helpText="Keep a record of open wallets, and reopen them on startup."/>
</Field>
<Field text="Validate derivations:">
<UnlabeledToggleSwitch fx:id="validateDerivationPaths" />
<HelpLabel helpText="Disallow keystores to have derivation paths that match the defaults for other script types."/>
@@ -8,6 +8,8 @@
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.AddressTreeTable?>
<?import org.controlsfx.glyphfont.Glyph?>
<GridPane hgap="10.0" vgap="10.0" stylesheets="@addresses.css, @wallet.css, @../general.css" styleClass="wallet-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.AddressesController">
<padding>
<Insets left="25.0" right="25.0" top="15.0" bottom="25.0" />
@@ -21,7 +23,17 @@
</rowConstraints>
<BorderPane GridPane.columnIndex="0" GridPane.rowIndex="0">
<top>
<Label styleClass="addresses-treetable-label" text="Receiving Addresses"/>
<HBox alignment="CENTER_LEFT">
<Label styleClass="addresses-treetable-label" text="Receive Addresses"/>
<Button onAction="#exportReceiveAddresses" styleClass="icon-button">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="ARROW_CIRCLE_DOWN" fontSize="12" />
</graphic>
<tooltip>
<Tooltip text="Export receive addresses as CSV" />
</tooltip>
</Button>
</HBox>
</top>
<center>
<AddressTreeTable fx:id="receiveTable" />
@@ -29,7 +41,17 @@
</BorderPane>
<BorderPane GridPane.columnIndex="0" GridPane.rowIndex="1">
<top>
<Label styleClass="addresses-treetable-label" text="Change Addresses"/>
<HBox alignment="CENTER_LEFT">
<Label styleClass="addresses-treetable-label" text="Change Addresses"/>
<Button onAction="#exportChangeAddresses" styleClass="icon-button">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="ARROW_CIRCLE_DOWN" fontSize="12" />
</graphic>
<tooltip>
<Tooltip text="Export change addresses as CSV" />
</tooltip>
</Button>
</HBox>
</top>
<center>
<AddressTreeTable fx:id="changeTable" />
@@ -41,7 +41,7 @@
<TextField fx:id="label" maxWidth="160"/>
</Field>
<Field text="Master fingerprint:">
<TextField fx:id="fingerprint" maxWidth="80" promptText="00000000"/> <HelpLabel helpText="A master fingerprint is the first 4 bytes of the master public key hash.\nIt is safe to use any valid value (00000000) for Watch Only Wallets." />
<TextField fx:id="fingerprint" maxWidth="80" promptText="00000000"/> <HelpLabel helpText="The master fingerprint uniquely identifies this keystore using the first 4 bytes of the master public key hash.\nIt is safe to use any valid value (00000000) for Watch Only Wallets." />
</Field>
<Field text="Derivation:">
<TextField fx:id="derivation" maxWidth="200"/> <HelpLabel helpText="The derivation path to the xpub from the master private key.\nFor safety, derivations that match defaults for other script types are not valid." />
@@ -104,3 +104,10 @@
#transactionDiagram .utxo-label:hover .button .label .text {
-fx-fill: -fx-text-base-color;
}
#targetBlocks .track {
-fx-background-color: -fx-shadow-highlight-color,
linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border),
linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)),
linear-gradient(to right, #c8416466 0%, #a0a1a766 50%, #a0a1a700 100%);
}
@@ -83,10 +83,10 @@
</HBox>
<Form GridPane.columnIndex="0" GridPane.rowIndex="2">
<Fieldset inputGrow="SOMETIMES">
<Field fx:id="targetBlocksField" text="Block target">
<Field fx:id="targetBlocksField" text="Blocks:">
<Slider fx:id="targetBlocks" snapToTicks="true" showTickLabels="true" showTickMarks="true" />
</Field>
<Field fx:id="feeRangeField" text="Rate Range:">
<Field fx:id="feeRangeField" text="Range:">
<Slider fx:id="feeRange" snapToTicks="false" showTickLabels="true" showTickMarks="true" />
</Field>
<Field fx:id="feeRateField" text="Rate:">
@@ -112,6 +112,9 @@
<padding>
<Insets left="25.0" right="25.0" bottom="25.0" />
</padding>
<HBox AnchorPane.leftAnchor="0" spacing="20">
<Button fx:id="export" text="Export..." onAction="#exportWallet" />
</HBox>
<HBox AnchorPane.rightAnchor="10" spacing="20">
<Button text="Advanced..." onAction="#showAdvanced" />
<Button fx:id="revert" text="Revert" cancelButton="true" />
@@ -23,15 +23,3 @@
.chart-line-symbol.selected {
-fx-background-color: rgba(30, 136, 207, 0.6);
}
#exportCsv {
-fx-border-style: none;
-fx-border-width: 0;
-fx-border-insets: 0;
-fx-background-color: transparent;
-fx-opacity: 0.7;
}
#exportCsv:hover {
-fx-opacity: 1.0;
}
@@ -41,7 +41,7 @@
</Field>
<Field text="Transactions:">
<CopyableLabel fx:id="transactionCount" />
<Button fx:id="exportCsv" maxHeight="25" onAction="#exportCSV" translateY="-1">
<Button fx:id="exportCsv" maxHeight="25" onAction="#exportCSV" translateY="-1" styleClass="icon-button">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="ARROW_CIRCLE_DOWN" fontSize="12" />
</graphic>
Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

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