Compare commits

...
88 Commits
Author SHA1 Message Date
Craig Raw 9872fc9221 v1.3.0 2021-03-11 14:48:41 +02:00
Craig Raw 84d2db427c dont allow scheduled enumerate to run while interactive commands are running 2021-03-11 14:37:19 +02:00
Craig Raw cfbf9187e6 update linux and windows hwi binaries to post rc.2 HEAD 2021-03-11 14:01:00 +02:00
Craig Raw 5478ac05b8 show error when importing tpubs from usb on mainnet etc 2021-03-11 11:39:19 +02:00
Craig Raw 836bd6bc40 display error on sendpin correctly 2021-03-11 11:13:30 +02:00
Craig Raw 1bab07fc5f dont allow duplicate loading wallet references 2021-03-11 10:37:30 +02:00
Craig Raw 4b3de044c3 remove unused --addr_type parameter when using hwi displayaddress with --desc 2021-03-11 10:36:44 +02:00
Craig Raw f2c2fc6071 fix exception on debug logging when sending from two or more utxos on the same derivation 2021-03-11 10:33:51 +02:00
Craig Raw 4dd52eb9dd handle csv export with mempool txes 2021-03-11 10:08:41 +02:00
Craig Raw e45fdc72a0 reduce log level on electrs notification errors to quieten logs 2021-03-10 14:17:40 +02:00
Craig Raw d67dd515c4 stop progress bar when loading wallet is closed 2021-03-10 13:49:21 +02:00
Craig Raw ff962aa5f0 add overpaid indicator to fee priority 2021-03-10 13:16:08 +02:00
Craig Raw c0e44b2c55 show online docs and log file from help menu 2021-03-10 12:51:17 +02:00
Craig Raw f0c239d625 upgrade to hwi-2.0.0-rc.2, display addresses on usb devices for multisig wallets 2021-03-10 11:31:19 +02:00
Craig Raw 58a31f435e show tooltips on fee priority label, minor ui improvements 2021-03-10 11:30:06 +02:00
Craig Raw c49439d541 add menu item to show introduction again 2021-03-08 17:00:32 +02:00
Craig Raw 0070cbfb1e use and display fractional tx virtual size, indicate sizes on unfinalized txes may change 2021-03-08 16:22:22 +02:00
Craig Raw 7ed75fc83d add fee rate priority indicator 2021-03-08 15:25:03 +02:00
Craig Raw 13d701b0a7 indicate when initially loading a wallets transactions in status bar 2021-03-08 10:45:29 +02:00
Craig Raw 07ed9c857b improve placeholder text for private electrum url hostname 2021-03-08 09:36:21 +02:00
Craig Raw 32ab53ba03 append tor debug output to test results 2021-03-08 09:35:21 +02:00
Craig Raw 68344e981c set fiat value on scanning payment uri 2021-03-08 09:21:04 +02:00
Craig Raw 10c1574a4a provide framework for application logging 2021-03-08 09:18:48 +02:00
Craig Raw 3a853e2ab1 fix text width issue with overlay on linux 2021-03-05 13:18:24 +02:00
Craig Raw 0f639dc2c2 clear transport on a failed test connection, try ssl or vice-versa on electrum connection failure 2021-03-05 09:16:00 +02:00
Craig Raw a801773545 max button retains state to adjust amount as fee changes 2021-03-04 13:00:39 +02:00
Craig Raw ffbf4bb0db default to mempool size fee rates selection if not configured 2021-03-03 15:36:33 +02:00
Craig Raw 11e46155a1 indicate welcome dialog connections are demonstration only 2021-03-03 15:19:56 +02:00
Craig Raw f369b3ad00 add command line option to set log level 2021-03-01 16:32:11 +02:00
Craig Raw 6068666415 prompt to retry on invalid password 2021-03-01 15:48:47 +02:00
Craig Raw 6a58e8a799 dont lose labels when rescanning, even if app is restarted 2021-03-01 15:27:54 +02:00
Craig Raw 3bd2f69157 add change password button, delete backups option on changing password 2021-02-26 16:03:18 +02:00
Craig Raw 03ec0c9da1 v1.2.0 2021-02-25 12:08:19 +02:00
Craig Raw 47fc0e11eb test various possible derivation paths when electrum seed is provided 2021-02-25 11:54:47 +02:00
Craig Raw 27f4b3b3ae add signet support 2021-02-24 13:39:35 +02:00
Craig Raw bb8c9a19cf fix configure later path 2021-02-24 11:11:31 +02:00
Craig Raw 16da6ae224 cross-platform followup 2021-02-23 16:19:04 +02:00
Craig Raw 152b55f7f0 replace welcome dialog 2021-02-23 13:56:28 +02:00
Craig Raw 62d83151d7 add config option to disable script type derivation validation 2021-02-22 16:05:18 +02:00
Craig Raw 9060a4b284 ensure psbt files always have correct extension 2021-02-22 14:55:31 +02:00
Craig Raw 759de8e047 add public server retry period with backoff 2021-02-18 09:27:01 +02:00
Craig Raw 1e3dd61b57 followup 2021-02-17 13:57:11 +02:00
Craig Raw d13bc8c313 add additional public servers, restart immediately on another when public server connection fails 2021-02-17 13:42:19 +02:00
Craig Raw 9b6ef1f3ca change export csv icon 2021-02-17 11:18:29 +02:00
Craig Raw 173078dd8d followup to script type descriptions 2021-02-17 11:10:08 +02:00
Craig Raw ae2db6e204 improve script type descriptions and help text 2021-02-17 10:24:21 +02:00
Craig Raw 5abea8c594 followup fixes for linux 2021-02-17 10:00:26 +02:00
Craig Raw cb94d91e98 update welcome and about dialogs for public electrum server 2021-02-16 16:57:10 +02:00
Craig Raw 454e4b4c4b add public electrum server type 2021-02-16 16:38:36 +02:00
Craig Raw 48a63c25d2 adjust csv icon position 2021-02-16 12:05:57 +02:00
Craig Raw 0fdc90523e export transactions to csv 2021-02-16 11:57:34 +02:00
Craig Raw 3203308d82 maybe fix help tooltips not appearing 2021-02-16 10:08:44 +02:00
Craig Raw 83a7f5c562 v1.1.0 2021-02-11 11:41:37 +02:00
Craig Raw 55e0648fc8 fix help text to match updated cobo firmware 2021-02-11 11:16:51 +02:00
Craig Raw b517b575da add custom dmg installer background 2021-02-11 11:15:38 +02:00
Craig Raw 890b4098d7 add refresh wallet menu item 2021-02-10 10:23:42 +02:00
Craig Raw 11a0e3765b avoid losing tx, address and txo labels when performing a full wallet refresh 2021-02-09 17:32:36 +02:00
Craig Raw 3ffb09c5e6 fix java version 2021-02-08 19:58:03 +02:00
Craig Raw 622b681109 add blue wallet vault import and export and add wallet export via qr 2021-02-08 16:29:59 +02:00
Craig Raw c27a576b3d make passphrase entry a masked password field 2021-02-08 12:42:46 +02:00
Craig Raw 7bc8bdc13e provide help tooltips on policy types and script types 2021-02-08 12:31:34 +02:00
Craig Raw 0097a1ecbc also show legacy qr display for imported keystores 2021-02-08 09:36:24 +02:00
Craig Raw 525d57ec73 handle rbf fee increase for consolidation outputs 2021-02-04 12:14:42 +02:00
Craig Raw e3d7bb57ee move internal tor to a top level app service, support non-ssl proxying 2021-02-04 12:00:49 +02:00
Craig Raw 095518e858 fix bitcoin core cookie dir not present npe 2021-01-28 12:45:12 +02:00
Craig Raw ac470f64c1 revert dynamic tx detail pane 2021-01-28 12:34:32 +02:00
Craig Raw 30215b0dac dynamically resize components on window height change 2021-01-28 12:29:20 +02:00
Craig Raw 563252c823 v1.0.0 2021-01-28 11:51:38 +02:00
Craig Raw 3a7ffe95e8 fix core wallet field - disabled when connected 2021-01-28 11:41:28 +02:00
Craig Raw b588a4ee02 always show scan time remaining 2021-01-28 11:19:57 +02:00
Craig Raw ab0611fabc update bwt to latest HEAD 2021-01-28 11:02:19 +02:00
Craig Raw a121bb5f26 add passport hww single and multisig importing 2021-01-28 10:43:54 +02:00
Craig Raw 2fc01e0345 resize on initial scene change 2021-01-27 10:08:47 +02:00
Craig Raw 650c3657a5 set tx hex divider lower when window height is reduced 2021-01-27 09:16:58 +02:00
Craig Raw d836eb515e further reduce min height to 730px on all platforms 2021-01-27 08:57:46 +02:00
Craig Raw 717bc9fec2 set default core wallet name if offline mode is initially selected 2021-01-26 18:28:07 +02:00
Craig Raw 1ad6da86b1 reduce tx diagram height to fit min resolution of 768px 2021-01-26 18:25:34 +02:00
Craig Raw 54680a5692 fix bug with trezor/keepkey keystore import, show hwi errors on all related dialogs 2021-01-26 14:39:49 +02:00
Craig Raw 6ef5e79b39 greater min window height on windows and linux 2021-01-26 13:23:46 +02:00
Craig Raw 1dbf51b406 reduce min window height by 50px 2021-01-26 12:19:25 +02:00
Craig Raw 8a3aa0d06e revert to java 14.0.2 on windows due to https://bugs.openjdk.java.net/browse/JDK-8254920 2021-01-25 16:55:21 +02:00
Craig Raw ca99efa215 only open mac-specific module on mac build 2021-01-25 15:54:12 +02:00
Craig Raw cc202ce4ca minor ui tweaks 2021-01-25 15:15:10 +02:00
Craig Raw ad44fe2335 use java major version only 2021-01-25 14:44:38 +02:00
Craig Raw 452242f208 upgrade github actions jdk to 15.0.1.hs.adpt 2021-01-25 14:41:33 +02:00
Craig Raw ff83af2e0d add hd capture toggle for qr scanning 2021-01-25 14:00:17 +02:00
Craig Raw aacecc8517 initialize bwt only on connection, default to core multi-wallet use 2021-01-20 13:03:47 +02:00
Craig Raw a9a3eef157 truncate long electrum wallet labels 2021-01-18 13:39:12 +02:00
112 changed files with 2553 additions and 637 deletions
+6
View File
@@ -12,7 +12,13 @@ jobs:
- uses: actions/checkout@v2
with:
submodules: true
- name: Set up JDK 15.0.2
if: ${{ runner.os == 'Linux' }}
uses: joschi/setup-jdk@v2
with:
java-version: 15
- name: Set up JDK 14.0.2
if: ${{ runner.os == 'Windows' }}
uses: actions/setup-java@v1
with:
java-version: 14.0.2
+7 -4
View File
@@ -28,7 +28,7 @@ If you prefer to run Sparrow directly from source, it can be launched with
`./sparrow`
Java 14 must be installed.
Java 14 or higher must be installed.
## Configuration
@@ -43,12 +43,15 @@ Usage: sparrow [options]
Path to Sparrow home folder
--help, -h
Show usage
--level, -l
Set log level
Possible Values: [ERROR, WARN, INFO, DEBUG, TRACE]
--network, -n
Network to use
Possible Values: [mainnet, testnet, regtest]
Possible Values: [mainnet, testnet, regtest, signet]
```
As a fallback, the network (mainnet, testnet or regtest) can also be set using an environment variable `SPARROW_NETWORK`. For example:
As a fallback, the network (mainnet, testnet, regtest or signet) can also be set using an environment variable `SPARROW_NETWORK`. For example:
`export SPARROW_NETWORK=testnet`
@@ -64,7 +67,7 @@ OSX | ~/.sparrow
Linux | ~/.sparrow
Windows | %APPDATA%/Sparrow
Testnet and regtest configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
Testnet, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
## Reporting Issues
+7 -3
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.beryx.jlink' version '2.22.0'
}
def sparrowVersion = '0.9.10'
def sparrowVersion = '1.3.0'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -69,7 +69,8 @@ dependencies {
exclude group: 'org.openjfx', module: 'javafx-web'
exclude group: 'org.openjfx', module: 'javafx-media'
}
implementation('dev.bwt:bwt-jni:0.1.6')
implementation('dev.bwt:bwt-jni:0.1.7')
implementation('net.sourceforge.javacsv:javacsv:2.0')
testImplementation('junit:junit:4.12')
}
@@ -145,10 +146,13 @@ jlink {
"--add-opens=javafx.controls/com.sun.javafx.scene.control=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.menu=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop"]
if(os.macOsX) {
jvmArgs += "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"
}
}
addExtraDependencies("javafx")
jpackage {
+1 -1
Submodule drongo updated: 3e91bdb46c...e974c3f422
+2 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.10</string>
<string>1.3.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -30,7 +30,7 @@
<key>CFBundleVersion</key>
<string>100</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (C) 2020</string>
<string>Copyright (C) 2021</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSCameraUsageDescription</key>
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -24,6 +24,7 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.transaction.TransactionController;
import com.sparrowwallet.sparrow.transaction.TransactionData;
@@ -33,6 +34,7 @@ import com.sparrowwallet.sparrow.wallet.WalletForm;
import de.codecentric.centerdevice.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
@@ -99,6 +101,9 @@ public class AppController implements Initializable {
@FXML
private CheckMenuItem showTxHex;
@FXML
private MenuItem refreshWallet;
@FXML
private StackPane rootStack;
@@ -115,6 +120,8 @@ public class AppController implements Initializable {
private Timeline statusTimeline;
private final Set<Wallet> loadingWallets = new LinkedHashSet<>();
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -223,9 +230,11 @@ public class AppController implements Initializable {
hideEmptyUsedAddresses.setSelected(Config.get().isHideEmptyUsedAddresses());
showTxHex.setSelected(Config.get().isShowTransactionHex());
exportWallet.setDisable(true);
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
serverToggle.setDisable(Config.get().getServerType() == null);
onlineProperty().bindBidirectional(serverToggle.selectedProperty());
onlineProperty().addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
@@ -254,6 +263,28 @@ public class AppController implements Initializable {
}
}
public void showIntroduction(ActionEvent event) {
WelcomeDialog welcomeDialog = new WelcomeDialog();
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER);
preferencesDialog.showAndWait();
}
}
public void showDocumentation(ActionEvent event) throws IOException {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/docs");
}
public void showLogFile(ActionEvent event) throws IOException {
File logFile = new File(Storage.getSparrowHome(), "sparrow.log");
if(logFile.exists()) {
AppServices.get().getApplication().getHostServices().showDocument(logFile.toPath().toUri().toString());
} else {
AppServices.showErrorDialog("Log file unavailable", "Cannot find log file at " + logFile.getCanonicalPath());
}
}
public void showAbout(ActionEvent event) {
Stage aboutStage = getAboutStage();
aboutStage.show();
@@ -442,6 +473,10 @@ public class AppController implements Initializable {
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) {
@@ -537,7 +572,18 @@ public class AppController implements Initializable {
}
private void setServerToggleTooltip(Integer currentBlockHeight) {
serverToggle.setTooltip(new Tooltip(AppServices.isConnected() ? "Connected to " + Config.get().getServerAddress() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") : "Disconnected"));
Tooltip tooltip = new Tooltip(getServerToggleTooltipText(currentBlockHeight));
tooltip.setShowDuration(Duration.seconds(15));
serverToggle.setTooltip(tooltip);
}
private String getServerToggleTooltipText(Integer currentBlockHeight) {
if(AppServices.isConnected()) {
return "Connected to " + Config.get().getServerAddress() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") +
(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? "\nWarning! You are connected to a public server and sharing your transaction data with it.\nFor better privacy, consider using your own Bitcoin Core node or private Electrum server." : "");
}
return "Disconnected";
}
public void newWallet(ActionEvent event) {
@@ -548,7 +594,7 @@ public class AppController implements Initializable {
File walletFile = Storage.getWalletFile(nameAndBirthDate.getName());
Storage storage = new Storage(walletFile);
Wallet wallet = new Wallet(nameAndBirthDate.getName(), PolicyType.SINGLE, ScriptType.P2WPKH, nameAndBirthDate.getBirthDate());
addWalletTabOrWindow(storage, wallet, false);
addWalletTabOrWindow(storage, wallet, null, false);
}
}
@@ -573,13 +619,13 @@ public class AppController implements Initializable {
Storage storage = new Storage(file);
FileType fileType = IOUtils.getFileType(file);
if(FileType.JSON.equals(fileType)) {
Wallet wallet = storage.loadWallet();
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(wallet, null);
if(!wallet.isValid()) {
Storage.WalletBackupAndKey walletBackupAndKey = storage.loadWallet();
checkWalletNetwork(walletBackupAndKey.wallet);
restorePublicKeysFromSeed(walletBackupAndKey.wallet, null);
if(!walletBackupAndKey.wallet.isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
addWalletTabOrWindow(storage, wallet, forceSameWindow);
addWalletTabOrWindow(storage, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
} else if(FileType.BINARY.equals(fileType)) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> optionalPassword = dlg.showAndWait();
@@ -591,22 +637,25 @@ public class AppController implements Initializable {
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage, password);
loadWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Storage.WalletAndKey walletAndKey = loadWalletService.getValue();
Storage.WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
try {
checkWalletNetwork(walletAndKey.wallet);
restorePublicKeysFromSeed(walletAndKey.wallet, walletAndKey.key);
addWalletTabOrWindow(storage, walletAndKey.wallet, forceSameWindow);
checkWalletNetwork(walletBackupAndKey.wallet);
restorePublicKeysFromSeed(walletBackupAndKey.wallet, walletBackupAndKey.key);
addWalletTabOrWindow(storage, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
} catch(Exception e) {
showErrorDialog("Error Opening Wallet", e.getMessage());
} finally {
walletAndKey.key.clear();
walletBackupAndKey.key.clear();
}
});
loadWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
Throwable exception = loadWalletService.getException();
if(exception instanceof InvalidPasswordException) {
showErrorDialog("Invalid Password", "The wallet password was invalid.");
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> openWalletFile(file, forceSameWindow));
}
} else {
if(!attemptImportWallet(file, password)) {
log.error("Error Opening Wallet", exception);
@@ -686,7 +735,11 @@ public class AppController implements Initializable {
}
private boolean attemptImportWallet(File file, SecureString password) {
List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(), new Electrum(), new SpecterDesktop(), new CoboVaultSinglesig(), new CoboVaultMultisig());
List<WalletImport> walletImporters = List.of(new ColdcardSinglesig(), new ColdcardMultisig(),
new Electrum(),
new SpecterDesktop(),
new CoboVaultSinglesig(), new CoboVaultMultisig(),
new PassportSinglesig());
for(WalletImport importer : walletImporters) {
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
@@ -754,7 +807,7 @@ public class AppController implements Initializable {
if(password.isPresent()) {
if(password.get().length() == 0) {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
addWalletTabOrWindow(storage, wallet, false);
addWalletTabOrWindow(storage, wallet, null, false);
} else {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
keyDerivationService.setOnSucceeded(workerStateEvent -> {
@@ -767,7 +820,7 @@ public class AppController implements Initializable {
key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
wallet.encrypt(key);
storage.setEncryptionPubKey(encryptionPubKey);
addWalletTabOrWindow(storage, wallet, false);
addWalletTabOrWindow(storage, wallet, null, false);
} finally {
encryptionFullKey.clear();
if(key != null) {
@@ -824,7 +877,20 @@ public class AppController implements Initializable {
messageSignDialog.showAndWait();
}
public void addWalletTabOrWindow(Storage storage, Wallet wallet, boolean forceSameWindow) {
public void refreshWallet(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
Wallet wallet = walletTabData.getWallet();
Wallet pastWallet = wallet.copy();
walletTabData.getStorage().backupTempWallet();
wallet.clearHistory();
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
}
}
public void addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage);
if(existingWalletWindow instanceof Stage) {
Stage existingWalletStage = (Stage)existingWalletWindow;
@@ -839,13 +905,13 @@ public class AppController implements Initializable {
AppController appController = AppServices.newAppWindow(stage);
stage.toFront();
stage.setX(AppServices.get().getWalletWindowMaxX() + 30);
appController.addWalletTab(storage, wallet);
appController.addWalletTab(storage, wallet, backupWallet);
} else {
addWalletTab(storage, wallet);
addWalletTab(storage, wallet, backupWallet);
}
}
public void addWalletTab(Storage storage, Wallet wallet) {
public void addWalletTab(Storage storage, Wallet wallet, Wallet backupWallet) {
try {
String name = storage.getWalletFile().getName();
if(name.endsWith(".json")) {
@@ -864,7 +930,7 @@ public class AppController implements Initializable {
EventManager.get().post(new WalletOpeningEvent(storage, wallet));
//Note that only one WalletForm is created per wallet tab, and registered to listen for events. All wallet controllers (except SettingsController) share this instance.
WalletForm walletForm = new WalletForm(storage, wallet);
WalletForm walletForm = new WalletForm(storage, wallet, backupWallet);
EventManager.get().register(walletForm);
controller.setWalletForm(walletForm);
@@ -1048,11 +1114,19 @@ public class AppController implements Initializable {
}
public void setServerType(ServerType serverType) {
if(serverType == ServerType.PUBLIC_ELECTRUM_SERVER && !serverToggle.getStyleClass().contains("public-server")) {
serverToggle.getStyleClass().add("public-server");
} else {
serverToggle.getStyleClass().remove("public-server");
}
if(serverType == ServerType.BITCOIN_CORE && !serverToggle.getStyleClass().contains("core-server")) {
serverToggle.getStyleClass().add("core-server");
} else {
serverToggle.getStyleClass().remove("core-server");
}
serverToggle.setDisable(false);
}
public void setTheme(ActionEvent event) {
@@ -1269,12 +1343,79 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void connectionStart(ConnectionStartEvent event) {
statusUpdated(new StatusEvent(event.getStatus(), 120));
}
@Subscribe
public void connectionFailed(ConnectionFailedEvent event) {
String reason = event.getException().getCause() != null ? event.getException().getCause().getMessage() : event.getException().getMessage();
String status = "Connection failed: " + reason;
statusUpdated(new StatusEvent(status));
}
@Subscribe
public void connection(ConnectionEvent event) {
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
statusUpdated(new StatusEvent(status));
}
@Subscribe
public void disconnection(DisconnectionEvent event) {
serverToggle.setDisable(false);
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith("Connection error")) {
statusUpdated(new StatusEvent("Disconnected"));
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
if(event.getClosedWalletTabData().stream().map(WalletTabData::getWallet).anyMatch(loadingWallets::remove) && loadingWallets.isEmpty()) {
if(statusBar.getText().equals("Loading transactions...")) {
statusBar.setText("");
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
}
}
@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());
}
}
}
@Subscribe
public void walletHistoryFinished(WalletHistoryFinishedEvent event) {
if(getOpenWallets().containsKey(event.getWallet())) {
if(statusBar.getText().equals("Loading transactions...")) {
statusBar.setText("");
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
loadingWallets.remove(event.getWallet());
}
}
@Subscribe
public void bwtBootStatus(BwtBootStatusEvent event) {
serverToggle.setDisable(true);
statusUpdated(new StatusEvent(event.getStatus(), 60));
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0.01);
if(AppServices.isConnecting()) {
statusUpdated(new StatusEvent(event.getStatus(), 60));
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0.01);
}
}
}
@@ -1293,7 +1434,7 @@ public class AppController implements Initializable {
public void bwtScanStatus(BwtScanStatusEvent event) {
serverToggle.setDisable(true);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Scanning... (" + event.getProgress() + "% complete" + (event.getProgress() > 30 ? ", " + event.getRemainingAsString() + " remaining)" : ")")));
statusUpdated(new StatusEvent("Scanning... (" + event.getProgress() + "% complete, " + event.getRemainingAsString() + " remaining)"));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
@@ -1309,14 +1450,21 @@ public class AppController implements Initializable {
}
@Subscribe
public void disconnection(DisconnectionEvent event) {
public void torBootStatus(TorBootStatusEvent event) {
serverToggle.setDisable(true);
statusUpdated(new StatusEvent(event.getStatus(), 120));
}
@Subscribe
public void torFailedStatus(TorFailedStatusEvent event) {
serverToggle.setDisable(false);
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith("Connection error")) {
statusUpdated(new StatusEvent("Disconnected"));
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
statusUpdated(new StatusEvent(event.getStatus()));
}
@Subscribe
public void torReadyStatus(TorReadyStatusEvent event) {
serverToggle.setDisable(false);
statusUpdated(new StatusEvent(event.getStatus()));
}
@Subscribe
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
@@ -21,6 +22,7 @@ import javafx.beans.value.ObservableValue;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Worker;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
@@ -30,11 +32,14 @@ import javafx.scene.text.Font;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
@@ -44,9 +49,10 @@ import java.util.stream.Collectors;
public class AppServices {
private static final Logger log = LoggerFactory.getLogger(AppServices.class);
private static final int SERVER_PING_PERIOD = 1 * 60 * 1000;
private static final int ENUMERATE_HW_PERIOD = 30 * 1000;
private static final int RATES_PERIOD = 5 * 60 * 1000;
private static final int SERVER_PING_PERIOD_SECS = 60;
private static final int PUBLIC_SERVER_RETRY_PERIOD_SECS = 3;
private static final int ENUMERATE_HW_PERIOD_SECS = 30;
private static final int RATES_PERIOD_SECS = 5 * 60;
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
@@ -67,6 +73,8 @@ public class AppServices {
private VersionCheckService versionCheckService;
private TorService torService;
private static Integer currentBlockHeight;
private static Map<Integer, Double> targetBlockFeeRates;
@@ -85,14 +93,10 @@ public class AppServices {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean online) {
if(online) {
restartService(connectionService);
if(ratesService.getExchangeSource() != ExchangeSource.NONE) {
restartService(ratesService);
}
if(Config.get().isCheckNewVersions()) {
restartService(versionCheckService);
if(Config.get().requiresTor() && !isTorRunning()) {
torService.start();
} else {
restartServices();
}
} else {
connectionService.cancel();
@@ -102,6 +106,44 @@ public class AppServices {
}
};
public AppServices(MainApp application) {
this.application = application;
EventManager.get().register(this);
}
public void start() {
Config config = Config.get();
connectionService = createConnectionService();
ratesService = createRatesService(config.getExchangeSource(), config.getFiatCurrency());
versionCheckService = createVersionCheckService();
torService = createTorService();
onlineProperty.addListener(onlineServicesListener);
if(config.getMode() == Mode.ONLINE) {
if(config.requiresTor()) {
torService.start();
} else {
restartServices();
}
}
}
private void restartServices() {
Config config = Config.get();
if(config.hasServerAddress()) {
restartService(connectionService);
}
if(config.isFetchRates()) {
restartService(ratesService);
}
if(config.isCheckNewVersions()) {
restartService(versionCheckService);
}
}
private void restartService(ScheduledService<?> service) {
if(service.isRunning()) {
service.cancel();
@@ -116,33 +158,6 @@ public class AppServices {
}
}
public AppServices(MainApp application) {
this.application = application;
EventManager.get().register(this);
}
public void start() {
Config config = Config.get();
connectionService = createConnectionService();
if(config.getMode() == Mode.ONLINE && config.getServerAddress() != null && !config.getServerAddress().isEmpty()) {
connectionService.start();
}
ExchangeSource source = config.getExchangeSource() != null ? config.getExchangeSource() : DEFAULT_EXCHANGE_SOURCE;
Currency currency = config.getFiatCurrency() != null ? config.getFiatCurrency() : DEFAULT_FIAT_CURRENCY;
ratesService = createRatesService(source, currency);
if(config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) {
ratesService.start();
}
versionCheckService = createVersionCheckService();
if(config.getMode() == Mode.ONLINE && config.isCheckNewVersions()) {
versionCheckService.start();
}
onlineProperty.addListener(onlineServicesListener);
}
public void stop() {
if(connectionService != null) {
connectionService.cancel();
@@ -155,21 +170,25 @@ public class AppServices {
if(versionCheckService != null) {
versionCheckService.cancel();
}
if(Tor.getDefault() != null) {
Tor.getDefault().shutdown();
}
}
private ElectrumServer.ConnectionService createConnectionService() {
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService();
connectionService.setPeriod(new Duration(SERVER_PING_PERIOD));
connectionService.setPeriod(Duration.seconds(SERVER_PING_PERIOD_SECS));
connectionService.setRestartOnFailure(true);
EventManager.get().register(connectionService);
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
if(connectionService.isRunning()) {
EventManager.get().post(new StatusEvent(newValue));
connectionService.setOnRunning(workerStateEvent -> {
if(!ElectrumServer.isConnected()) {
EventManager.get().post(new ConnectionStartEvent(Config.get().getServerAddress()));
}
});
connectionService.setOnSucceeded(successEvent -> {
connectionService.setPeriod(Duration.seconds(SERVER_PING_PERIOD_SECS));
connectionService.setRestartOnFailure(true);
onlineProperty.removeListener(onlineServicesListener);
@@ -192,6 +211,12 @@ public class AppServices {
onlineProperty.setValue(false);
onlineProperty.addListener(onlineServicesListener);
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
List<String> otherServers = Arrays.stream(PublicElectrumServer.values()).map(PublicElectrumServer::getUrl).filter(url -> !url.equals(Config.get().getPublicElectrumServer())).collect(Collectors.toList());
Config.get().setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
connectionService.setPeriod(Duration.seconds(PUBLIC_SERVER_RETRY_PERIOD_SECS));
}
log.debug("Connection failed", failEvent.getSource().getException());
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException()));
});
@@ -200,8 +225,10 @@ public class AppServices {
}
private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) {
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(exchangeSource, currency);
ratesService.setPeriod(new Duration(RATES_PERIOD));
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(
exchangeSource == null ? DEFAULT_EXCHANGE_SOURCE : exchangeSource,
currency == null ? DEFAULT_FIAT_CURRENCY : currency);
ratesService.setPeriod(Duration.seconds(RATES_PERIOD_SECS));
ratesService.setRestartOnFailure(true);
ratesService.setOnSucceeded(successEvent -> {
@@ -229,7 +256,7 @@ public class AppServices {
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() {
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null);
enumerateService.setPeriod(new Duration(ENUMERATE_HW_PERIOD));
enumerateService.setPeriod(Duration.seconds(ENUMERATE_HW_PERIOD_SECS));
enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue();
@@ -246,6 +273,45 @@ public class AppServices {
return enumerateService;
}
private TorService createTorService() {
TorService torService = new TorService();
torService.setPeriod(Duration.hours(1000));
torService.setRestartOnFailure(true);
torService.setOnRunning(workerStateEvent -> {
EventManager.get().post(new TorBootStatusEvent());
});
torService.setOnSucceeded(workerStateEvent -> {
Tor.setDefault(torService.getValue());
torService.cancel();
restartServices();
EventManager.get().post(new TorReadyStatusEvent());
});
torService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new TorFailedStatusEvent(workerStateEvent.getSource().getException()));
});
return torService;
}
public static boolean isTorRunning() {
return Tor.getDefault() != null;
}
public static Proxy getProxy() {
Config config = Config.get();
if(config.isUseProxy()) {
HostAndPort proxy = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
} else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
}
return null;
}
static void initialize(MainApp application) {
INSTANCE = new AppServices(application);
}
@@ -265,7 +331,7 @@ public class AppServices {
stage.setTitle("Sparrow");
stage.setMinWidth(650);
stage.setMinHeight(800);
stage.setMinHeight(730);
stage.setScene(scene);
stage.getIcons().add(new Image(MainApp.class.getResourceAsStream("/image/sparrow.png")));
@@ -278,6 +344,10 @@ public class AppServices {
}
}
public static boolean isReducedWindowHeight(Node node) {
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
}
public MainApp getApplication() {
return application;
}
@@ -399,16 +469,6 @@ public class AppServices {
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
String banner = event.getServerBanner();
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
EventManager.get().post(new StatusEvent(status));
}
@Subscribe
public void connectionFailed(ConnectionFailedEvent event) {
String reason = event.getException().getCause() != null ? event.getException().getCause().getMessage() : event.getException().getMessage();
String status = "Connection error: " + reason;
EventManager.get().post(new StatusEvent(status));
}
@Subscribe
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow;
import com.beust.jcommander.Parameter;
import com.sparrowwallet.drongo.Network;
import org.slf4j.event.Level;
public class Args {
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
@@ -10,6 +11,9 @@ public class Args {
@Parameter(names = { "--network", "-n" }, description = "Network to use")
public Network network;
@Parameter(names = { "--level", "-l" }, description = "Set log level")
public Level level;
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
public boolean help;
}
@@ -1,14 +1,17 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.sparrowwallet.drongo.Drongo;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.control.WelcomeDialog;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.FileType;
import com.sparrowwallet.sparrow.io.IOUtils;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Bwt;
import com.sparrowwallet.sparrow.net.PublicElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
@@ -25,7 +28,7 @@ import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "0.9.10";
public static final String APP_VERSION = "1.3.0";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -50,24 +53,33 @@ public class MainApp extends Application {
boolean createNewWallet = false;
Mode mode = Config.get().getMode();
if(mode == null) {
WelcomeDialog welcomeDialog = new WelcomeDialog(getHostServices());
WelcomeDialog welcomeDialog = new WelcomeDialog();
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
if(optionalMode.isPresent()) {
mode = optionalMode.get();
Config.get().setMode(mode);
Config.get().setCoreWallet(Bwt.DEFAULT_CORE_WALLET);
if(mode.equals(Mode.ONLINE)) {
PreferencesDialog preferencesDialog = new PreferencesDialog(PreferenceGroup.SERVER, true);
Optional<Boolean> optNewWallet = preferencesDialog.showAndWait();
createNewWallet = optNewWallet.isPresent() && optNewWallet.get();
} else if(Network.get() == Network.MAINNET) {
Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
Config.get().setPublicElectrumServer(PublicElectrumServer.values()[new Random().nextInt(PublicElectrumServer.values().length)].getUrl());
}
}
}
if(Config.get().getServerType() == null && Config.get().getCoreServer() == null && Config.get().getElectrumServer() != null) {
Config.get().setServerType(ServerType.ELECTRUM_SERVER);
} else if(Config.get().getServerType() == ServerType.BITCOIN_CORE && Config.get().getCoreWallet() == null) {
Config.get().setCoreMultiWallet(Boolean.TRUE);
Config.get().setCoreWallet("");
}
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
AppController appController = AppServices.newAppWindow(stage);
if(createNewWallet) {
@@ -108,6 +120,10 @@ public class MainApp extends Application {
System.exit(0);
}
if(args.level != null) {
Drongo.setRootLogLevel(args.level);
}
if(args.dir != null) {
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
@@ -131,6 +147,11 @@ public class MainApp extends Application {
Network.set(Network.TESTNET);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
}
if(Network.get() != Network.MAINNET) {
getLogger().info("Using " + Network.get() + " configuration");
}
@@ -0,0 +1,12 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.drongo.LogHandler;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import org.slf4j.event.Level;
public class TorLogHandler implements LogHandler {
@Override
public void handleLog(String threadName, Level level, String message, String loggerName, long timestamp, StackTraceElement[] callerData) {
EventManager.get().post(new TorStatusEvent(message));
}
}
@@ -0,0 +1,130 @@
package com.sparrowwallet.sparrow;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.fxml.FXML;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.controlsfx.control.StatusBar;
public class WelcomeController {
@FXML
private VBox welcomeBox;
@FXML
private VBox step1;
@FXML
private VBox step2;
@FXML
private VBox step3;
@FXML
private VBox step4;
@FXML
private StatusBar serverStatus;
@FXML
private UnlabeledToggleSwitch serverToggle;
public void initializeView() {
step1.managedProperty().bind(step1.visibleProperty());
step2.managedProperty().bind(step2.visibleProperty());
step3.managedProperty().bind(step3.visibleProperty());
step4.managedProperty().bind(step4.visibleProperty());
step2.setVisible(false);
step3.setVisible(false);
step4.setVisible(false);
welcomeBox.getStyleClass().add("offline");
serverStatus.setText("Offline");
serverToggle.selectedProperty().addListener((observable, oldValue, newValue) -> {
serverStatus.setText(newValue ? "Connected (demonstration only)" : "Offline");
});
}
public boolean next() {
if(step1.isVisible()) {
step1.setVisible(false);
step2.setVisible(true);
welcomeBox.getStyleClass().clear();
welcomeBox.getStyleClass().add("public-electrum");
PauseTransition wait = new PauseTransition(Duration.millis(200));
wait.setOnFinished((e) -> {
serverToggle.setSelected(true);
serverStatus.setText("Connected to a Public Server (demonstration only)");
});
wait.play();
return true;
}
if(step2.isVisible()) {
step2.setVisible(false);
step3.setVisible(true);
welcomeBox.getStyleClass().clear();
welcomeBox.getStyleClass().add("bitcoin-core");
serverToggle.setSelected(true);
serverStatus.setText("Connected to Bitcoin Core (demonstration only)");
return true;
}
if(step3.isVisible()) {
step3.setVisible(false);
step4.setVisible(true);
welcomeBox.getStyleClass().clear();
welcomeBox.getStyleClass().add("private-electrum");
serverToggle.setSelected(true);
serverStatus.setText("Connected to a Private Electrum Server (demonstration only)");
}
return false;
}
public boolean back() {
if(step2.isVisible()) {
step2.setVisible(false);
step1.setVisible(true);
welcomeBox.getStyleClass().clear();
welcomeBox.getStyleClass().add("offline");
PauseTransition wait = new PauseTransition(Duration.millis(200));
wait.setOnFinished((e) -> {
serverToggle.setSelected(false);
serverStatus.setText("Offline");
});
wait.play();
return false;
}
if(step3.isVisible()) {
step3.setVisible(false);
step2.setVisible(true);
welcomeBox.getStyleClass().clear();
welcomeBox.getStyleClass().add("public-electrum");
serverToggle.setSelected(true);
serverStatus.setText("Connected to a Public Server (demonstration only)");
return true;
}
if(step4.isVisible()) {
step4.setVisible(false);
step3.setVisible(true);
welcomeBox.getStyleClass().clear();
welcomeBox.getStyleClass().add("bitcoin-core");
serverToggle.setSelected(true);
serverStatus.setText("Connected to Bitcoin Core (demonstration only)");
return true;
}
return false;
}
}
@@ -0,0 +1,69 @@
package com.sparrowwallet.sparrow;
import javafx.event.ActionEvent;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import java.io.IOException;
public class WelcomeDialog extends Dialog<Mode> {
public WelcomeDialog() {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
try {
FXMLLoader welcomeLoader = new FXMLLoader(AppServices.class.getResource("welcome.fxml"));
dialogPane.setContent(welcomeLoader.load());
WelcomeController welcomeController = welcomeLoader.getController();
welcomeController.initializeView();
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
final ButtonType backButtonType = new javafx.scene.control.ButtonType("Back", ButtonBar.ButtonData.LEFT);
final ButtonType onlineButtonType = new javafx.scene.control.ButtonType("Configure Server", ButtonBar.ButtonData.APPLY);
final ButtonType offlineButtonType = new javafx.scene.control.ButtonType(AppServices.isConnected() ? "Done" : "Later or Offline Mode", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(nextButtonType, backButtonType, onlineButtonType, offlineButtonType);
Button nextButton = (Button)dialogPane.lookupButton(nextButtonType);
Button backButton = (Button)dialogPane.lookupButton(backButtonType);
Button onlineButton = (Button)dialogPane.lookupButton(onlineButtonType);
Button offlineButton = (Button)dialogPane.lookupButton(offlineButtonType);
nextButton.managedProperty().bind(nextButton.visibleProperty());
backButton.managedProperty().bind(backButton.visibleProperty());
onlineButton.managedProperty().bind(onlineButton.visibleProperty());
offlineButton.managedProperty().bind(offlineButton.visibleProperty());
backButton.setDisable(true);
onlineButton.visibleProperty().bind(nextButton.visibleProperty().not());
offlineButton.visibleProperty().bind(nextButton.visibleProperty().not());
nextButton.addEventFilter(ActionEvent.ACTION, event -> {
if(!welcomeController.next()) {
nextButton.setVisible(false);
onlineButton.setDefaultButton(true);
}
backButton.setDisable(false);
event.consume();
});
backButton.addEventFilter(ActionEvent.ACTION, event -> {
nextButton.setVisible(true);
if(!welcomeController.back()) {
backButton.setDisable(true);
}
event.consume();
});
setResultConverter(dialogButton -> dialogButton == onlineButtonType ? Mode.ONLINE : Mode.OFFLINE);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.wallet.SendController;
import javafx.beans.NamedArg;
import javafx.scene.Node;
import javafx.scene.chart.Axis;
@@ -27,9 +28,11 @@ public class BlockTargetFeeRatesChart extends LineChart<String, Number> {
for(Iterator<Integer> targetBlocksIter = targetBlocksFeeRates.keySet().iterator(); targetBlocksIter.hasNext(); ) {
Integer targetBlocks = targetBlocksIter.next();
String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+");
XYChart.Data<String, Number> data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks));
feeRateSeries.getData().add(data);
if(SendController.TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
String category = targetBlocks + (targetBlocksIter.hasNext() ? "" : "+");
XYChart.Data<String, Number> data = new XYChart.Data<>(category, targetBlocksFeeRates.get(targetBlocks));
feeRateSeries.getData().add(data);
}
}
if(selectedTargetBlocks != null) {
@@ -84,15 +84,15 @@ public class CoinTreeTable extends TreeTableView<Entry> {
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate());
Optional<Date> optDate = dlg.showAndWait();
if(optDate.isPresent()) {
wallet.setBirthDate(optDate.get());
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(storage != null) {
//Trigger background save of birthdate
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletSettingsChangedEvent(wallet, storage.getWalletFile()));
}
Wallet pastWallet = wallet.copy();
storage.backupTempWallet();
wallet.setBirthDate(optDate.get());
//Trigger background save of birthdate
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, storage.getWalletFile()));
}
});
if(wallet.getBirthDate() == null) {
@@ -1,22 +1,22 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.AddressDisplayedEvent;
import com.sparrowwallet.sparrow.io.Device;
import java.util.List;
import java.util.stream.Collectors;
public class DeviceAddressDialog extends DeviceDialog<String> {
private final Wallet wallet;
private final KeyDerivation keyDerivation;
private final OutputDescriptor outputDescriptor;
public DeviceAddressDialog(List<String> operationFingerprints, Wallet wallet, KeyDerivation keyDerivation) {
super(operationFingerprints);
public DeviceAddressDialog(Wallet wallet, OutputDescriptor outputDescriptor) {
super(outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList()));
this.wallet = wallet;
this.keyDerivation = keyDerivation;
this.outputDescriptor = outputDescriptor;
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -26,7 +26,7 @@ public class DeviceAddressDialog extends DeviceDialog<String> {
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, keyDerivation, device);
return new DevicePane(wallet, outputDescriptor, device);
}
@Subscribe
@@ -89,6 +89,8 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360);
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
}
private void scan() {
@@ -99,6 +101,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
});
enumerateService.setOnFailed(workerStateEvent -> {
deviceAccordion.getPanes().clear();
scanBox.setVisible(true);
scanLabel.setText(workerStateEvent.getSource().getException().getMessage());
});
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
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.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
@@ -15,7 +16,6 @@ import com.sparrowwallet.sparrow.event.MessageSignedEvent;
import com.sparrowwallet.sparrow.event.PSBTSignedEvent;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
@@ -25,7 +25,6 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import org.controlsfx.control.textfield.CustomPasswordField;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -36,6 +35,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.stream.Collectors;
public class DevicePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(DevicePane.class);
@@ -43,6 +43,7 @@ public class DevicePane extends TitledDescriptionPane {
private final DeviceOperation deviceOperation;
private final Wallet wallet;
private final PSBT psbt;
private final OutputDescriptor outputDescriptor;
private final KeyDerivation keyDerivation;
private final String message;
private final Device device;
@@ -63,6 +64,7 @@ public class DevicePane extends TitledDescriptionPane {
this.deviceOperation = DeviceOperation.IMPORT;
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
@@ -73,18 +75,7 @@ public class DevicePane extends TitledDescriptionPane {
createSetPassphraseButton();
createImportButton();
if(device.getError() != null) {
setError("Error", device.getError());
Platform.runLater(() -> {
setExpanded(true);
});
} else if(device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else {
showOperationButton();
}
initialise(device);
buttonBox.getChildren().addAll(setPassphraseButton, importButton);
}
@@ -94,6 +85,7 @@ public class DevicePane extends TitledDescriptionPane {
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null;
this.psbt = psbt;
this.outputDescriptor = null;
this.keyDerivation = null;
this.message = null;
this.device = device;
@@ -104,23 +96,18 @@ public class DevicePane extends TitledDescriptionPane {
createSetPassphraseButton();
createSignButton();
if (device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else {
showOperationButton();
}
initialise(device);
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
}
public DevicePane(Wallet wallet, KeyDerivation keyDerivation, Device device) {
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
this.wallet = wallet;
this.psbt = null;
this.keyDerivation = keyDerivation;
this.outputDescriptor = outputDescriptor;
this.keyDerivation = null;
this.message = null;
this.device = device;
@@ -130,13 +117,7 @@ public class DevicePane extends TitledDescriptionPane {
createSetPassphraseButton();
createDisplayAddressButton();
if (device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else {
showOperationButton();
}
initialise(device);
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
}
@@ -146,6 +127,7 @@ public class DevicePane extends TitledDescriptionPane {
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
this.psbt = null;
this.outputDescriptor = null;
this.keyDerivation = keyDerivation;
this.message = message;
this.device = device;
@@ -156,15 +138,24 @@ public class DevicePane extends TitledDescriptionPane {
createSetPassphraseButton();
createSignMessageButton();
if (device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
initialise(device);
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
private void initialise(Device device) {
if(device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else if(device.getError() != null) {
setError("Error", device.getError());
Platform.runLater(() -> {
setExpanded(true);
});
} else {
showOperationButton();
}
buttonBox.getChildren().addAll(setPassphraseButton, signMessageButton);
}
@Override
@@ -247,7 +238,8 @@ public class DevicePane extends TitledDescriptionPane {
displayAddressButton.managedProperty().bind(displayAddressButton.visibleProperty());
displayAddressButton.setVisible(false);
if(device.getFingerprint() != null && !device.getFingerprint().equals(keyDerivation.getMasterFingerprint())) {
List<String> fingerprints = outputDescriptor.getExtendedPublicKeys().stream().map(extKey -> outputDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList());
if(device.getFingerprint() != null && !fingerprints.contains(device.getFingerprint())) {
displayAddressButton.setDisable(true);
}
}
@@ -316,7 +308,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private Node getPassphraseEntry() {
CustomTextField passphraseField = (CustomTextField)TextFields.createClearableTextField();
CustomPasswordField passphraseField = (CustomPasswordField)TextFields.createClearablePasswordField();
passphrase.bind(passphraseField.textProperty());
HBox.setHgrow(passphraseField, Priority.ALWAYS);
@@ -384,7 +376,7 @@ public class DevicePane extends TitledDescriptionPane {
}
});
sendPinService.setOnFailed(workerStateEvent -> {
setError(sendPinService.getException().getMessage(), null);
setError("Error", sendPinService.getException().getMessage());
enterPinButton.setDisable(false);
});
setDescription("Unlocking...");
@@ -450,14 +442,18 @@ public class DevicePane extends TitledDescriptionPane {
getXpubService.setOnSucceeded(workerStateEvent -> {
String xpub = getXpubService.getValue();
Keystore keystore = new Keystore();
keystore.setLabel(device.getModel().toDisplayString());
keystore.setSource(KeystoreSource.HW_USB);
keystore.setWalletModel(device.getModel());
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
try {
Keystore keystore = new Keystore();
keystore.setLabel(device.getModel().toDisplayString());
keystore.setSource(KeystoreSource.HW_USB);
keystore.setWalletModel(device.getModel());
keystore.setKeyDerivation(new KeyDerivation(device.getFingerprint(), derivationPath));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
EventManager.get().post(new KeystoreImportEvent(keystore));
EventManager.get().post(new KeystoreImportEvent(keystore));
} catch(Exception e) {
setError("Could not retrieve xpub", e.getMessage());
}
});
getXpubService.setOnFailed(workerStateEvent -> {
setError("Could not retrieve xpub", getXpubService.getException().getMessage());
@@ -485,7 +481,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private void displayAddress() {
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), keyDerivation.getDerivationPath());
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(device, passphrase.get(), wallet.getScriptType(), outputDescriptor);
displayAddressService.setOnSucceeded(successEvent -> {
String address = displayAddressService.getValue();
EventManager.get().post(new AddressDisplayedEvent(address));
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Transaction;
@@ -24,8 +25,8 @@ import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
class EntryCell extends TreeTableCell<Entry, Entry> {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
public class EntryCell extends TreeTableCell<Entry, Entry> {
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
public EntryCell() {
super();
@@ -173,9 +174,16 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE)
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
int vSize = tx.getVirtualSize();
double vSize = tx.getVirtualSize();
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
Collections.shuffle(walletUtxos);
@@ -189,6 +197,7 @@ class EntryCell extends TreeTableCell<Entry, Entry> {
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
externalOutputs.removeAll(ourOutputs);
externalOutputs.addAll(consolidationOutputs);
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
return new Payment(txOutput.getScript().getToAddresses()[0], transactionEntry.getLabel(), txOutput.getValue(), false);
@@ -2,44 +2,81 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.StorageEvent;
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.Storage;
import com.sparrowwallet.sparrow.io.WalletExport;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
import javafx.scene.control.ToggleButton;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
public class FileWalletExportPane extends TitledDescriptionPane {
private final Wallet wallet;
private final WalletExport exporter;
private final boolean scannable;
public FileWalletExportPane(Wallet wallet, WalletExport exporter) {
super(exporter.getName(), "Wallet file export", exporter.getWalletExportDescription(), "image/" + exporter.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.exporter = exporter;
this.scannable = exporter.isWalletExportScannable();
buttonBox.getChildren().clear();
buttonBox.getChildren().add(createButton());
}
@Override
protected Control createButton() {
Button exportButton = new Button("Export Wallet...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
exportButton.setOnAction(event -> {
exportWallet();
});
return exportButton;
if(scannable) {
ToggleButton showButton = new ToggleButton("Show...");
Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA);
cameraGlyph.setFontSize(12);
showButton.setGraphic(cameraGlyph);
showButton.setOnAction(event -> {
showButton.setSelected(false);
exportQR();
});
ToggleButton fileButton = new ToggleButton("Export File...");
fileButton.setAlignment(Pos.CENTER_RIGHT);
fileButton.setOnAction(event -> {
fileButton.setSelected(false);
exportFile();
});
SegmentedButton segmentedButton = new SegmentedButton();
segmentedButton.getButtons().addAll(showButton, fileButton);
return segmentedButton;
} else {
Button exportButton = new Button("Export File...");
exportButton.setAlignment(Pos.CENTER_RIGHT);
exportButton.setOnAction(event -> {
exportFile();
});
return exportButton;
}
}
private void exportWallet() {
private void exportQR() {
exportWallet(null);
}
private void exportFile() {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -59,17 +96,18 @@ public class FileWalletExportPane extends TitledDescriptionPane {
WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final File walletFile = AppServices.get().getOpenWallets().get(wallet).getWalletFile();
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
exportWallet(file, decryptedWallet);
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Failed"));
setError("Export Error", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(file, TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
@@ -79,9 +117,21 @@ public class FileWalletExportPane extends TitledDescriptionPane {
private void exportWallet(File file, Wallet decryptedWallet) {
try {
OutputStream outputStream = new FileOutputStream(file);
exporter.exportWallet(decryptedWallet, outputStream);
EventManager.get().post(new WalletExportEvent(decryptedWallet));
if(file != null) {
OutputStream outputStream = new FileOutputStream(file);
exporter.exportWallet(decryptedWallet, outputStream);
EventManager.get().post(new WalletExportEvent(decryptedWallet));
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(decryptedWallet, outputStream);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
}
qrDisplayDialog.showAndWait();
}
} catch(Exception e) {
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
@@ -72,6 +72,9 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE)));
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
Region region = new Region();
HBox.setHgrow(region, Priority.SOMETIMES);
@@ -97,7 +100,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.CENTER_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().addAll(label, scriptTypeComboBox, region, importFileButton);
contentBox.getChildren().addAll(label, scriptTypeComboBox, helpLabel, region, importFileButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
@@ -1,10 +1,15 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseEvent;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
public class HelpLabel extends Label {
@@ -14,8 +19,10 @@ public class HelpLabel extends Label {
super("", getHelpGlyph());
tooltip = new Tooltip();
tooltip.textProperty().bind(helpTextProperty());
setTooltip(tooltip);
tooltip.setShowDuration(Duration.seconds(15));
getStyleClass().add("help-label");
Platform.runLater(() -> setTooltip(tooltip));
}
private static Glyph getHelpGlyph() {
@@ -312,7 +312,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
StackPane wordsPane = (StackPane)getContent();
StackPane confirmPane = new StackPane();
confirmPane.setMaxWidth(300);
confirmPane.setMaxWidth(350);
confirmPane.setMaxHeight(100);
Region region = new Region();
region.setMinWidth(confirmPane.getMaxWidth());
@@ -225,19 +225,15 @@ public class QRDisplayDialog extends Dialog<UR> {
private void setLegacyGraphic(ToggleButton legacy, boolean useLegacyEncoding) {
if(useLegacyEncoding) {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE, "success"));
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
} else {
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE, null));
legacy.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE));
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName, String styleClass) {
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(12);
if(styleClass != null) {
glyph.getStyleClass().add(styleClass);
}
glyph.setFontSize(11);
return glyph;
}
}
@@ -1,5 +1,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.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
@@ -23,14 +25,17 @@ import com.sparrowwallet.hummingbird.ResultType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.URDecoder;
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.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -40,6 +45,7 @@ import java.nio.CharBuffer;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
@@ -58,14 +64,21 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)");
private final ObjectProperty<WebcamResolution> webcamResolutionProperty = new SimpleObjectProperty<>(WebcamResolution.VGA);
public QRScanDialog() {
this.decoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder();
this.webcamService = new WebcamService(WebcamResolution.VGA);
if(Config.get().isHdCapture()) {
webcamResolutionProperty.set(WebcamResolution.HD);
}
this.webcamService = new WebcamService(webcamResolutionProperty.get(), new QRScanListener());
WebcamView webcamView = new WebcamView(webcamService);
final DialogPane dialogPane = getDialogPane();
final DialogPane dialogPane = new QRScanDialogPane();
setDialogPane(dialogPane);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
@@ -78,14 +91,27 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
Platform.runLater(() -> setResult(new Result(failedEvent.getSource().getException())));
});
webcamService.start();
webcamResolutionProperty.addListener((observable, oldValue, newResolution) -> {
if(newResolution != null) {
setHeight(newResolution == WebcamResolution.HD ? (getHeight() - 100) : (getHeight() + 100));
}
webcamService.cancel();
});
setOnCloseRequest(event -> {
Platform.runLater(webcamService::cancel);
boolean isHdCapture = (webcamResolutionProperty.get() == WebcamResolution.HD);
if(Config.get().isHdCapture() != isHdCapture) {
Config.get().setHdCapture(isHdCapture);
}
Platform.runLater(() -> webcamResolutionProperty.set(null));
});
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(660);
dialogPane.setPrefHeight(550);
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);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
}
@@ -425,6 +451,73 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
}
private class QRScanListener implements WebcamListener {
@Override
public void webcamOpen(WebcamEvent webcamEvent) {
}
@Override
public void webcamClosed(WebcamEvent webcamEvent) {
if(webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
Platform.runLater(() -> {
if(!webcamService.isRunning()) {
webcamService.reset();
webcamService.start();
}
});
}
}
@Override
public void webcamDisposed(WebcamEvent webcamEvent) {
}
@Override
public void webcamImageObtained(WebcamEvent webcamEvent) {
}
}
private class QRScanDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
ToggleButton hd = new ToggleButton(buttonType.getText());
hd.setSelected(webcamResolutionProperty.get() == WebcamResolution.HD);
hd.setGraphicTextGap(5);
setHdGraphic(hd, hd.isSelected());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(hd, buttonData);
hd.selectedProperty().addListener((observable, oldValue, newValue) -> {
webcamResolutionProperty.set(newValue ? WebcamResolution.HD : WebcamResolution.VGA);
setHdGraphic(hd, newValue);
});
return hd;
}
return super.createButton(buttonType);
}
private void setHdGraphic(ToggleButton hd, boolean isHd) {
if(isHd) {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.CHECK_CIRCLE));
} else {
hd.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE));
}
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class Result {
public final Transaction transaction;
public final PSBT psbt;
@@ -30,14 +30,17 @@ import java.util.*;
import java.util.stream.Collectors;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
private static final int MAX_PAYMENTS = 6;
private static final double DIAGRAM_HEIGHT = 230.0;
private static final int MAX_UTXOS = 7;
private static final int MAX_PAYMENTS = 5;
private static final double DIAGRAM_HEIGHT = 215.0;
private static final int TOOLTIP_SHOW_DELAY = 50;
private WalletTransaction walletTx;
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
setMaxHeight(getDiagramHeight());
if(walletTx == null) {
getChildren().clear();
} else {
@@ -131,7 +134,7 @@ public class TransactionDiagram extends GridPane {
Line topYaxis = new Line();
topYaxis.setStartX(width * 0.5);
topYaxis.setStartY(DIAGRAM_HEIGHT * 0.5 - 20.0);
topYaxis.setStartY(getDiagramHeight() * 0.5 - 20.0);
topYaxis.setEndX(width * 0.5);
topYaxis.setEndY(0);
topYaxis.getStyleClass().add("inputs-type");
@@ -145,16 +148,16 @@ public class TransactionDiagram extends GridPane {
Line bottomYaxis = new Line();
bottomYaxis.setStartX(width * 0.5);
bottomYaxis.setStartY(DIAGRAM_HEIGHT);
bottomYaxis.setStartY(getDiagramHeight());
bottomYaxis.setEndX(width * 0.5);
bottomYaxis.setEndY(DIAGRAM_HEIGHT * 0.5 + 20.0);
bottomYaxis.setEndY(getDiagramHeight() * 0.5 + 20.0);
bottomYaxis.getStyleClass().add("inputs-type");
Line bottomBracket = new Line();
bottomBracket.setStartX(width * 0.5);
bottomBracket.setStartY(DIAGRAM_HEIGHT);
bottomBracket.setStartY(getDiagramHeight());
bottomBracket.setEndX(width);
bottomBracket.setEndY(DIAGRAM_HEIGHT);
bottomBracket.setEndY(getDiagramHeight());
bottomBracket.getStyleClass().add("inputs-type");
group.getChildren().addAll(widthLine, topYaxis, topBracket, bottomYaxis, bottomBracket);
@@ -240,7 +243,7 @@ public class TransactionDiagram extends GridPane {
yaxisLine.setStartX(0);
yaxisLine.setStartY(0);
yaxisLine.setEndX(0);
yaxisLine.setEndY(DIAGRAM_HEIGHT);
yaxisLine.setEndY(getDiagramHeight());
yaxisLine.getStyleClass().add("boundary");
group.getChildren().add(yaxisLine);
@@ -259,9 +262,9 @@ public class TransactionDiagram extends GridPane {
double scaleFactor = (double)i / (numUtxos + 1);
int nodeHeight = 17;
double additional = (0.5 - scaleFactor) * ((double)nodeHeight);
curve.setStartY(scale(DIAGRAM_HEIGHT, scaleFactor, additional));
curve.setStartY(scale(getDiagramHeight(), scaleFactor, additional));
curve.setEndX(width);
curve.setEndY(scale(DIAGRAM_HEIGHT, 0.5, 0));
curve.setEndY(scale(getDiagramHeight(), 0.5, 0));
curve.setControlX1(scale(width, 0.2, 0));
curve.setControlY1(curve.getStartY());
@@ -320,12 +323,12 @@ public class TransactionDiagram extends GridPane {
curve.getStyleClass().add("output-line");
curve.setStartX(0);
curve.setStartY(scale(DIAGRAM_HEIGHT, 0.5, 0));
curve.setStartY(scale(getDiagramHeight(), 0.5, 0));
curve.setEndX(width);
double scaleFactor = (double)i / (numOutputs + 1);
int nodeHeight = 20;
double additional = (0.5 - scaleFactor) * ((double)nodeHeight);
curve.setEndY(scale(DIAGRAM_HEIGHT, scaleFactor, additional));
curve.setEndY(scale(getDiagramHeight(), scaleFactor, additional));
curve.setControlX1(scale(width, 0.2, 0));
curve.controlY1Property().bind(curve.startYProperty());
@@ -394,7 +397,7 @@ public class TransactionDiagram extends GridPane {
String txDesc = "Transaction";
Label txLabel = new Label(txDesc);
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + walletTx.getTransaction().getVirtualSize() + " vBytes");
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + String.format("%.2f", walletTx.getTransaction().getVirtualSize()) + " vBytes");
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.getStyleClass().add("transaction-tooltip");
txLabel.setTooltip(tooltip);
@@ -404,6 +407,14 @@ public class TransactionDiagram extends GridPane {
return txPane;
}
public double getDiagramHeight() {
if(AppServices.isReducedWindowHeight(this)) {
return DIAGRAM_HEIGHT - 40;
}
return DIAGRAM_HEIGHT;
}
private Node createSpacer() {
final Region spacer = new Region();
VBox.setVgrow(spacer, Priority.ALWAYS);
@@ -43,7 +43,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new SpecterDesktop());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop());
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -39,13 +39,13 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
Accordion importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
}
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop());
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
@@ -22,17 +22,24 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
private final CustomPasswordField passwordConfirm;
private final CheckBox backupExisting;
private final CheckBox changePassword;
private final CheckBox deleteBackups;
private boolean addingPassword;
public WalletPasswordDialog(PasswordRequirement requirement) {
this(null, requirement);
}
public WalletPasswordDialog(String walletName, PasswordRequirement requirement) {
this(null, requirement, false);
}
public WalletPasswordDialog(String walletName, PasswordRequirement requirement, boolean suggestChangePassword) {
this.requirement = requirement;
this.password = (CustomPasswordField)TextFields.createClearablePasswordField();
this.passwordConfirm = (CustomPasswordField)TextFields.createClearablePasswordField();
this.backupExisting = new CheckBox("Backup existing wallet first");
this.changePassword = new CheckBox("Change password");
this.deleteBackups = new CheckBox("Delete any backups");
final DialogPane dialogPane = getDialogPane();
setTitle("Wallet Password" + (walletName != null ? " - " + walletName : ""));
@@ -52,13 +59,21 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
content.getChildren().add(password);
content.getChildren().add(passwordConfirm);
if(requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_SET) {
content.getChildren().add(backupExisting);
backupExisting.setSelected(true);
}
if(requirement == PasswordRequirement.UPDATE_SET) {
content.getChildren().add(changePassword);
changePassword.selectedProperty().addListener((observable, oldValue, newValue) -> {
backupExisting.setVisible(!newValue);
});
changePassword.setSelected(suggestChangePassword);
}
if(requirement == PasswordRequirement.UPDATE_EMPTY || requirement == PasswordRequirement.UPDATE_SET) {
backupExisting.managedProperty().bind(backupExisting.visibleProperty());
deleteBackups.managedProperty().bind(deleteBackups.visibleProperty());
deleteBackups.visibleProperty().bind(backupExisting.visibleProperty().not());
content.getChildren().addAll(backupExisting, deleteBackups);
backupExisting.setSelected(true);
deleteBackups.setSelected(true);
}
dialogPane.setContent(content);
@@ -87,10 +102,14 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
okButton.setText("No Password");
passwordConfirm.setVisible(false);
passwordConfirm.setManaged(false);
backupExisting.setVisible(true);
addingPassword = false;
} else {
okButton.setText("Set Password");
passwordConfirm.setVisible(true);
passwordConfirm.setManaged(true);
backupExisting.setVisible(false);
addingPassword = true;
}
});
}
@@ -103,13 +122,17 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
}
public boolean isBackupExisting() {
return backupExisting.isSelected();
return !(addingPassword || isChangePassword()) && backupExisting.isSelected();
}
public boolean isChangePassword() {
return changePassword.isSelected();
}
public boolean isDeleteBackups() {
return (addingPassword || isChangePassword()) && deleteBackups.isSelected();
}
public enum PasswordRequirement {
LOAD("Please enter the wallet password:", "Unlock"),
UPDATE_NEW("Add a password to the wallet?\nLeave empty for none:", "No Password"),
@@ -1,6 +1,7 @@
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.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
@@ -13,15 +14,18 @@ import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import java.awt.image.BufferedImage;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
public class WebcamService extends Service<Image> {
private final WebcamResolution resolution ;
private WebcamResolution resolution;
private final WebcamListener listener;
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
public WebcamService(WebcamResolution resolution) {
public WebcamService(WebcamResolution resolution, WebcamListener listener) {
this.resolution = resolution;
this.listener = listener;
}
@Override
@@ -33,6 +37,9 @@ public class WebcamService extends Service<Image> {
try {
cam.setCustomViewSizes(resolution.getSize());
cam.setViewSize(resolution.getSize());
if(!Arrays.asList(cam.getWebcamListeners()).contains(listener)) {
cam.addWebcamListener(listener);
}
cam.open();
while(!isCancelled()) {
@@ -78,4 +85,8 @@ public class WebcamService extends Service<Image> {
public int getCamHeight() {
return resolution.getSize().height;
}
public void setResolution(WebcamResolution resolution) {
this.resolution = resolution;
}
}
@@ -1,100 +0,0 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.HostServices;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.control.HyperlinkLabel;
import org.controlsfx.control.StatusBar;
import org.controlsfx.control.ToggleSwitch;
public class WelcomeDialog extends Dialog<Mode> {
private final HostServices hostServices;
private ServerType serverType = ServerType.ELECTRUM_SERVER;
public WelcomeDialog(HostServices services) {
this.hostServices = services;
final DialogPane dialogPane = getDialogPane();
setTitle("Welcome to Sparrow");
dialogPane.setHeaderText("Welcome to Sparrow!");
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
dialogPane.setGraphic(imageView);
}
final ButtonType onlineButtonType = new javafx.scene.control.ButtonType("Configure Server", ButtonBar.ButtonData.OK_DONE);
final ButtonType offlineButtonType = new javafx.scene.control.ButtonType("Later or Offline Mode", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(onlineButtonType, offlineButtonType);
final VBox content = new VBox(20);
content.setPadding(new Insets(20, 20, 20, 20));
content.getChildren().add(createParagraph("Sparrow can operate in both an online and offline mode. In the online mode it connects to your Bitcoin Core node or Electrum server to display transaction history. In the offline mode it is useful as a transaction editor and as an airgapped multisig coordinator."));
content.getChildren().add(createParagraph("Connecting Sparrow to your Bitcoin Core node ensures your privacy, while connecting Sparrow to your own Electrum server ensures wallets load quicker, you have access to a full blockchain explorer, and your public keys are always encrypted on disk. Examples of Electrum servers include ElectrumX and electrs."));
content.getChildren().add(createParagraph("It's also possible to connect Sparrow to a public Electrum server (such as blockstream.info:700) but this is not recommended as you will share your public key information with that server."));
content.getChildren().add(createParagraph("You can change your mode at any time using the toggle in the status bar. A blue toggle indicates you are connected to an Electrum server, while a green toggle indicates you are connected to a Bitcoin Code node."));
content.getChildren().add(createStatusBar(onlineButtonType, offlineButtonType));
dialogPane.setContent(content);
setResultConverter(dialogButton -> dialogButton == onlineButtonType ? Mode.ONLINE : Mode.OFFLINE);
}
private Label createParagraph(String text) {
Label label = new Label(text);
label.setWrapText(true);
return label;
}
private StatusBar createStatusBar(ButtonType onlineButtonType, ButtonType offlineButtonType) {
StatusBar statusBar = new StatusBar();
statusBar.setText("Online Mode");
statusBar.getRightItems().add(createToggle(statusBar, onlineButtonType, offlineButtonType));
return statusBar;
}
private ToggleSwitch createToggle(StatusBar statusBar, ButtonType onlineButtonType, ButtonType offlineButtonType) {
ToggleSwitch toggleSwitch = new UnlabeledToggleSwitch();
toggleSwitch.setStyle("-fx-padding: 1px 0 0 0");
toggleSwitch.selectedProperty().addListener((observable, oldValue, newValue) -> {
Button onlineButton = (Button) getDialogPane().lookupButton(onlineButtonType);
onlineButton.setDefaultButton(newValue);
Button offlineButton = (Button) getDialogPane().lookupButton(offlineButtonType);
offlineButton.setDefaultButton(!newValue);
if(!newValue) {
serverType = (serverType == ServerType.BITCOIN_CORE ? ServerType.ELECTRUM_SERVER : ServerType.BITCOIN_CORE);
if(serverType == ServerType.BITCOIN_CORE && !toggleSwitch.getStyleClass().contains("core-server")) {
toggleSwitch.getStyleClass().add("core-server");
} else {
toggleSwitch.getStyleClass().remove("core-server");
}
}
statusBar.setText(newValue ? "Online Mode: " + serverType.getName() : "Offline Mode");
});
toggleSwitch.setSelected(true);
return toggleSwitch;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.AppServices;
public class ConnectionStartEvent {
private final String status;
public ConnectionStartEvent(String serverAddress) {
this.status = AppServices.isTorRunning() ? "Tor running, connecting to " + serverAddress + "..." : "Connecting to " + serverAddress + "...";
}
public String getStatus() {
return status;
}
}
@@ -1,14 +1,21 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
public class FeeRatesSelectionChangedEvent {
private final Wallet wallet;
private final FeeRatesSelection feeRatesSelection;
public FeeRatesSelectionChangedEvent(FeeRatesSelection feeRatesSelection) {
public FeeRatesSelectionChangedEvent(Wallet wallet, FeeRatesSelection feeRatesSelection) {
this.wallet = wallet;
this.feeRatesSelection = feeRatesSelection;
}
public Wallet getWallet() {
return wallet;
}
public FeeRatesSelection getFeeRateSelection() {
return feeRatesSelection;
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.event;
public class TorBootStatusEvent extends TorStatusEvent {
public TorBootStatusEvent() {
super("Starting Tor...");
}
}
@@ -0,0 +1,16 @@
package com.sparrowwallet.sparrow.event;
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()));
this.exception = exception;
}
public Throwable getException() {
return exception;
}
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.event;
public class TorReadyStatusEvent extends TorStatusEvent {
public TorReadyStatusEvent() {
super("Tor started");
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryFinishedEvent extends WalletHistoryStatusEvent {
public WalletHistoryFinishedEvent(Wallet wallet) {
super(wallet, false);
}
public WalletHistoryFinishedEvent(Wallet wallet, String errorMessage) {
super(wallet, errorMessage);
}
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryStartedEvent extends WalletHistoryStatusEvent {
public WalletHistoryStartedEvent(Wallet wallet) {
super(wallet, true);
}
}
@@ -24,7 +24,7 @@ public class WalletHistoryStatusEvent {
public WalletHistoryStatusEvent(Wallet wallet, String errorMessage) {
this.wallet = wallet;
this.loading = true;
this.loading = false;
this.statusMessage = null;
this.errorMessage = errorMessage;
}
@@ -12,13 +12,19 @@ import java.io.File;
* 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 WalletSettingsChangedEvent extends WalletChangedEvent {
private final Wallet pastWallet;
private final File walletFile;
public WalletSettingsChangedEvent(Wallet wallet, File walletFile) {
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
super(wallet);
this.pastWallet = pastWallet;
this.walletFile = walletFile;
}
public Wallet getPastWallet() {
return pastWallet;
}
public File getWalletFile() {
return walletFile;
}
@@ -15,6 +15,7 @@ public class FontAwesome5 extends GlyphFont {
* The individual glyphs offered by the FontAwesome5 font.
*/
public static enum Glyph implements INamedCharacter {
ARROW_CIRCLE_DOWN('\uf0ab'),
ANGLE_DOUBLE_RIGHT('\uf101'),
ARROW_DOWN('\uf063'),
ARROW_UP('\uf062'),
@@ -29,6 +30,7 @@ public class FontAwesome5 extends GlyphFont {
EXTERNAL_LINK_ALT('\uf35d'),
ELLIPSIS_H('\uf141'),
EYE('\uf06e'),
FILE_CSV('\uf6dd'),
HAND_HOLDING('\uf4bd'),
HAND_HOLDING_MEDICAL('\ue05c'),
HISTORY('\uf1da'),
@@ -38,6 +40,7 @@ public class FontAwesome5 extends GlyphFont {
LOCK_OPEN('\uf3c1'),
PEN_FANCY('\uf5ac'),
PLUS('\uf067'),
PLUS_CIRCLE('\uf055'),
QRCODE('\uf029'),
QUESTION_CIRCLE('\uf059'),
RANDOM('\uf074'),
@@ -45,7 +48,10 @@ public class FontAwesome5 extends GlyphFont {
SATELLITE_DISH('\uf7c0'),
SD_CARD('\uf7c2'),
SEARCH('\uf002'),
SQUARE('\uf0c8'),
TIMES_CIRCLE('\uf057'),
TOGGLE_OFF('\uf204'),
TOGGLE_ON('\uf205'),
TOOLS('\uf7d9'),
UNDO('\uf0e2'),
WALLET('\uf555');
@@ -0,0 +1,50 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class BlueWalletMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Blue Wallet Vault Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.BLUE_WALLET;
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
Wallet wallet = super.importWallet(inputStream, password);
for(Keystore keystore : wallet.getKeystores()) {
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Blue Wallet"));
keystore.setWalletModel(WalletModel.BLUE_WALLET);
}
return wallet;
}
@Override
public String getWalletImportDescription() {
return "Import file or QR created by using the Wallet > Export Coordination Setup feature on your Blue Wallet Vault wallet.";
}
@Override
public String getWalletExportDescription() {
return "Export file that can be read by Blue Wallet using the Add Wallet > Vault > Import wallet feature.";
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isWalletExportScannable() {
return true;
}
}
@@ -29,7 +29,7 @@ public class CoboVaultMultisig extends ColdcardMultisig {
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the Multisig Wallet > Show/Export XPUB feature on your Cobo Vault.";
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Cobo Vault.";
}
@Override
@@ -45,12 +45,12 @@ public class CoboVaultMultisig extends ColdcardMultisig {
@Override
public String getWalletImportDescription() {
return "Import file or QR created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault.";
return "Import file or QR created by using the Multisig Wallet > ... > Create Multisig Wallet feature on your Cobo Vault.";
}
@Override
public String getWalletExportDescription() {
return "Export file that can be read by your Cobo Vault using the Multisig Wallet > Import Multisig Wallet feature.";
return "Export file or QR that can be read by your Cobo Vault using the Multisig Wallet > ... > Import Multisig Wallet feature.";
}
@Override
@@ -62,4 +62,9 @@ public class CoboVaultMultisig extends ColdcardMultisig {
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public boolean isWalletExportScannable() {
return true;
}
}
@@ -21,7 +21,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the Settings > Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault.";
return "Import file or QR created by using the My Cobo Vault > ... > Export Wallet feature on your Cobo Vault.";
}
@Override
@@ -222,4 +222,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
public boolean isKeystoreImportScannable() {
return false;
}
@Override
public boolean isWalletExportScannable() {
return false;
}
}
@@ -57,7 +57,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
Map<String, JsonElement> map = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), stringStringMap);
if (map.get("xfp") == null) {
throw new ImportException("File was not a valid Coldcard wallet export");
throw new ImportException("File was not a valid " + getName() + " wallet export");
}
String masterFingerprint = map.get("xfp").getAsString();
@@ -107,7 +107,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Coldcard wallet was invalid: " + e.getMessage());
throw new ImportException("Imported " + getName() + " wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -4,10 +4,7 @@ import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.net.CoreAuthType;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.wallet.FeeRatesSelection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,6 +25,7 @@ public class Config {
private FeeRatesSelection feeRatesSelection;
private Currency fiatCurrency;
private ExchangeSource exchangeSource;
private boolean validateDerivationPaths = true;
private boolean groupByAddress = true;
private boolean includeMempoolChange = true;
private boolean notifyNewTransactions = true;
@@ -39,11 +37,14 @@ public class Config {
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private File hwi;
private boolean hdCapture;
private ServerType serverType;
private String publicElectrumServer;
private String coreServer;
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private Boolean coreMultiWallet;
private String coreWallet;
private String electrumServer;
private File electrumServerCert;
@@ -79,7 +80,7 @@ public class Config {
if(config != null) {
return config;
}
} catch (IOException e) {
} catch(Exception e) {
log.error("Error opening " + configFile.getAbsolutePath(), e);
//Ignore and assume no config
}
@@ -141,6 +142,10 @@ public class Config {
flush();
}
public boolean isFetchRates() {
return getExchangeSource() != ExchangeSource.NONE;
}
public ExchangeSource getExchangeSource() {
return exchangeSource;
}
@@ -150,6 +155,15 @@ public class Config {
flush();
}
public boolean isValidateDerivationPaths() {
return validateDerivationPaths;
}
public void setValidateDerivationPaths(boolean validateDerivationPaths) {
this.validateDerivationPaths = validateDerivationPaths;
flush();
}
public boolean isGroupByAddress() {
return groupByAddress;
}
@@ -249,6 +263,15 @@ public class Config {
flush();
}
public boolean isHdCapture() {
return hdCapture;
}
public void setHdCapture(boolean hdCapture) {
this.hdCapture = hdCapture;
flush();
}
public ServerType getServerType() {
return serverType;
}
@@ -258,8 +281,34 @@ public class Config {
flush();
}
public boolean hasServerAddress() {
return getServerAddress() != null && !getServerAddress().isEmpty();
}
public String getServerAddress() {
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : getElectrumServer();
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : (getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? getPublicElectrumServer() : getElectrumServer());
}
public boolean requiresTor() {
if(isUseProxy() || !hasServerAddress()) {
return false;
}
Protocol protocol = Protocol.getProtocol(getServerAddress());
if(protocol == null) {
return false;
}
return protocol.isOnionAddress(protocol.getServerHostAndPort(getServerAddress()));
}
public String getPublicElectrumServer() {
return publicElectrumServer;
}
public void setPublicElectrumServer(String publicElectrumServer) {
this.publicElectrumServer = publicElectrumServer;
flush();
}
public String getCoreServer() {
@@ -298,12 +347,21 @@ public class Config {
flush();
}
public Boolean getCoreMultiWallet() {
return coreMultiWallet;
}
public void setCoreMultiWallet(Boolean coreMultiWallet) {
this.coreMultiWallet = coreMultiWallet;
flush();
}
public String getCoreWallet() {
return coreWallet;
}
public void setCoreWallet(String coreWallet) {
this.coreWallet = (coreWallet == null || coreWallet.isEmpty() ? null : coreWallet);
this.coreWallet = coreWallet;
flush();
}
@@ -161,8 +161,18 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
keystore.setSeed(new DeterministicSeed(mnemonic, passphrase, 0, DeterministicSeed.Type.ELECTRUM));
if(derivationPath.equals("m/0")) {
derivationPath = "m/0'";
//Ensure the derivation path from the seed matches the provided xpub
String[] possibleDerivations = {"m", "m/0", "m/0'"};
for(String possibleDerivation : possibleDerivations) {
List<ChildNumber> derivation = KeyDerivation.parsePath(possibleDerivation);
DeterministicKey derivedKey = keystore.getExtendedMasterPrivateKey().getKey(derivation);
DeterministicKey derivedKeyPublicOnly = derivedKey.dropPrivateBytes().dropParent();
ExtendedKey xpub = new ExtendedKey(derivedKeyPublicOnly, derivedKey.getParentFingerprint(), derivation.isEmpty() ? ChildNumber.ZERO : derivation.get(derivation.size() - 1));
if(xpub.equals(xPub)) {
derivationPath = possibleDerivation;
break;
}
}
} else {
keystore.setSource(KeystoreSource.SW_WATCH);
@@ -173,6 +183,10 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, derivationPath));
keystore.setExtendedPublicKey(xPub);
keystore.setLabel(ek.label != null ? ek.label : "Electrum");
if(keystore.getLabel().length() > Keystore.MAX_LABEL_LENGTH) {
keystore.setLabel(keystore.getLabel().substring(0, Keystore.MAX_LABEL_LENGTH));
}
wallet.getKeystores().add(keystore);
ExtendedKey.Header xpubHeader = ExtendedKey.Header.fromExtendedKey(ek.xpub);
@@ -355,6 +369,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
return false;
}
@Override
public boolean isWalletExportScannable() {
return false;
}
@Override
public String getWalletExportDescription() {
return "Export this wallet as an Electrum wallet file.";
@@ -4,6 +4,7 @@ import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams;
import com.google.gson.*;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
@@ -29,7 +30,7 @@ import java.util.zip.ZipInputStream;
public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String TEMP_FILE_PREFIX = "hwi-1.2.1-";
private static final String VERSION_PREFIX = "hwi-2.0.0-rc.2";
private static boolean isPromptActive = false;
@@ -42,11 +43,17 @@ public class Hwi {
command = List.of(getHwiExecutable(Command.ENUMERATE).getAbsolutePath(), Command.ENUMERATE.toString());
}
isPromptActive = true;
String output = execute(command);
Device[] devices = getGson().fromJson(output, Device[].class);
if(devices == null) {
throw new ImportException("Error scanning, check devices are ready");
}
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
throw new ImportException(e);
} finally {
isPromptActive = false;
}
}
@@ -84,32 +91,33 @@ public class Hwi {
if(result.get("xpub") != null) {
return result.get("xpub").getAsString();
} else {
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
JsonElement error = result.get("error");
if(error != null) {
throw new ImportException(error.getAsString());
} else {
throw new ImportException("Could not retrieve xpub - reconnect your device and try again.");
}
}
} catch(IOException e) {
throw new ImportException(e);
}
}
public String displayAddress(Device device, String passphrase, ScriptType scriptType, String derivationPath) throws DisplayAddressException {
public String displayAddress(Device device, String passphrase, ScriptType scriptType, OutputDescriptor outputDescriptor) throws DisplayAddressException {
try {
if(!List.of(ScriptType.P2PKH, ScriptType.P2SH_P2WPKH, ScriptType.P2WPKH).contains(scriptType)) {
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only single sig types supported");
if(!Arrays.asList(ScriptType.SINGLE_HASH_TYPES).contains(scriptType)) {
throw new IllegalArgumentException("Cannot display address for script type " + scriptType + ": Only single hash types supported");
}
String type = null;
if(scriptType == ScriptType.P2SH_P2WPKH) {
type = "--sh_wpkh";
} else if(scriptType == ScriptType.P2WPKH) {
type = "--wpkh";
}
//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");
isPromptActive = false;
isPromptActive = true;
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
output = execute(getDeviceCommand(device, passphrase, Command.DISPLAY_ADDRESS, "--desc", descriptor));
} else {
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--path", derivationPath, type));
output = execute(getDeviceCommand(device, Command.DISPLAY_ADDRESS, "--desc", descriptor));
}
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
@@ -125,12 +133,14 @@ public class Hwi {
}
} catch(IOException e) {
throw new DisplayAddressException(e);
} finally {
isPromptActive = false;
}
}
public String signMessage(Device device, String passphrase, String message, String derivationPath) throws SignMessageException {
try {
isPromptActive = false;
isPromptActive = true;
String output;
if(passphrase != null && !passphrase.isEmpty() && device.getModel().externalPassphraseEntry()) {
output = execute(getDeviceCommand(device, passphrase, Command.SIGN_MESSAGE, message, derivationPath));
@@ -151,6 +161,8 @@ public class Hwi {
}
} catch(IOException e) {
throw new SignMessageException(e);
} finally {
isPromptActive = false;
}
}
@@ -165,7 +177,6 @@ public class Hwi {
} else {
output = execute(getDeviceCommand(device, Command.SIGN_TX, psbtBase64));
}
isPromptActive = false;
JsonObject result = JsonParser.parseString(output).getAsJsonObject();
if(result.get("psbt") != null) {
@@ -187,6 +198,8 @@ public class Hwi {
throw new SignTransactionException("Could not sign PSBT", e);
} catch(PSBTParseException e) {
throw new SignTransactionException("Could not parsed signed PSBT", e);
} finally {
isPromptActive = false;
}
}
@@ -199,7 +212,7 @@ public class Hwi {
private synchronized File getHwiExecutable(Command command) {
File hwiExecutable = Config.get().getHwi();
if(hwiExecutable != null && hwiExecutable.exists()) {
if(command.isTestFirst() && (!hwiExecutable.getAbsolutePath().contains(TEMP_FILE_PREFIX) || !testHwi(hwiExecutable))) {
if(command.isTestFirst() && (!hwiExecutable.getAbsolutePath().contains(VERSION_PREFIX) || !testHwi(hwiExecutable))) {
if(Platform.getCurrent() == Platform.OSX) {
deleteDirectory(hwiExecutable.getParentFile());
} else {
@@ -218,8 +231,8 @@ public class Hwi {
//The check will still happen on first invocation, but will not thereafter
//See https://github.com/bitcoin-core/HWI/issues/327 for details
if(platform == Platform.OSX) {
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/hwi-1.2.1-mac-amd64-signed.zip");
Path tempHwiDirPath = Files.createTempDirectory(TEMP_FILE_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + VERSION_PREFIX + "-mac-amd64-signed.zip");
Path tempHwiDirPath = Files.createTempDirectory(VERSION_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
File tempHwiDir = tempHwiDirPath.toFile();
//tempHwiDir.deleteOnExit();
log.debug("Using temp HWI path: " + tempHwiDir.getAbsolutePath());
@@ -227,16 +240,20 @@ public class Hwi {
File tempExec = null;
ZipInputStream zis = new ZipInputStream(inputStream);
ZipEntry zipEntry = zis.getNextEntry();
while (zipEntry != null) {
File newFile = newFile(tempHwiDir, zipEntry, ownerExecutableWritable);
//newFile.deleteOnExit();
FileOutputStream fos = new FileOutputStream(newFile);
ByteStreams.copy(zis, new FileOutputStream(newFile));
fos.flush();
fos.close();
while(zipEntry != null) {
if(zipEntry.isDirectory()) {
newDirectory(tempHwiDir, zipEntry, ownerExecutableWritable);
} else {
File newFile = newFile(tempHwiDir, zipEntry, ownerExecutableWritable);
//newFile.deleteOnExit();
FileOutputStream fos = new FileOutputStream(newFile);
ByteStreams.copy(zis, new FileOutputStream(newFile));
fos.flush();
fos.close();
if (zipEntry.getName().equals("hwi")) {
tempExec = newFile;
if(zipEntry.getName().equals("hwi")) {
tempExec = newFile;
}
}
zipEntry = zis.getNextEntry();
@@ -250,10 +267,10 @@ public class Hwi {
Path tempExecPath;
if(platform == Platform.WINDOWS) {
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null);
tempExecPath = Files.createTempFile(VERSION_PREFIX, null);
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(TEMP_FILE_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
tempExecPath = Files.createTempFile(VERSION_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
}
File tempExec = tempExecPath.toFile();
@@ -299,6 +316,20 @@ public class Hwi {
return directoryToBeDeleted.delete();
}
public static File newDirectory(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
Path path = Path.of(destDirPath, zipEntry.getName());
File destDir = Files.createDirectory(path, PosixFilePermissions.asFileAttribute(setFilePermissions)).toFile();
String destSubDirPath = destDir.getCanonicalPath();
if(!destSubDirPath.startsWith(destDirPath + File.separator)) {
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
}
return destDir;
}
public static File newFile(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
@@ -306,7 +337,7 @@ public class Hwi {
File destFile = Files.createFile(path, PosixFilePermissions.asFileAttribute(setFilePermissions)).toFile();
String destFilePath = destFile.getCanonicalPath();
if (!destFilePath.startsWith(destDirPath + File.separator)) {
if(!destFilePath.startsWith(destDirPath + File.separator)) {
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
}
@@ -324,30 +355,41 @@ public class Hwi {
private List<String> getDeviceCommand(Device device, Command command) throws IOException {
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--testnet");
}
addChainType(elements);
return elements;
}
private List<String> getDeviceCommand(Device device, Command command, String... commandData) throws IOException {
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), command.toString()));
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--testnet");
}
addChainType(elements);
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
return elements;
}
private List<String> getDeviceCommand(Device device, String passphrase, Command command, String... commandData) throws IOException {
List<String> elements = new ArrayList<>(List.of(getHwiExecutable(command).getAbsolutePath(), "--device-path", device.getPath(), "--device-type", device.getType(), "--password", passphrase, command.toString()));
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--testnet");
}
addChainType(elements);
elements.addAll(Arrays.stream(commandData).filter(Objects::nonNull).collect(Collectors.toList()));
return elements;
}
private void addChainType(List<String> elements) {
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - 1, "--chain");
elements.add(elements.size() - 1, getChainName(Network.get()));
}
}
private String getChainName(Network network) {
if(network == Network.MAINNET) {
return "main";
} else if(network == Network.TESTNET) {
return "test";
}
return network.toString();
}
public static class EnumerateService extends Service<List<Device>> {
private final String passphrase;
@@ -430,13 +472,13 @@ public class Hwi {
private final Device device;
private final String passphrase;
private final ScriptType scriptType;
private final String derivationPath;
private final OutputDescriptor outputDescriptor;
public DisplayAddressService(Device device, String passphrase, ScriptType scriptType, String derivationPath) {
public DisplayAddressService(Device device, String passphrase, ScriptType scriptType, OutputDescriptor outputDescriptor) {
this.device = device;
this.passphrase = passphrase;
this.scriptType = scriptType;
this.derivationPath = derivationPath;
this.outputDescriptor = outputDescriptor;
}
@Override
@@ -444,7 +486,7 @@ public class Hwi {
return new Task<>() {
protected String call() throws DisplayAddressException {
Hwi hwi = new Hwi();
return hwi.displayAddress(device, passphrase, scriptType, derivationPath);
return hwi.displayAddress(device, passphrase, scriptType, outputDescriptor);
}
};
}
@@ -0,0 +1,38 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class PassportMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Passport Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.PASSPORT;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Passport");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created from Settings > Pair Software Wallet > Sparrow > Multisig > microSD/QR on your Passport.";
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
}
@@ -0,0 +1,48 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class PassportSinglesig extends ColdcardSinglesig {
@Override
public String getName() {
return "Passport";
}
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created from Settings > Pair Software Wallet > Sparrow > Single Sig > microSD/QR on your Passport.";
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Passport");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public WalletModel getWalletModel() {
return WalletModel.PASSPORT;
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public String getWalletImportDescription() {
return getKeystoreImportDescription();
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
@@ -19,7 +20,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
try {
SpecterWallet specterWallet = new SpecterWallet();
specterWallet.label = wallet.getName();
specterWallet.blockheight = wallet.getStoredBlockHeight();
specterWallet.blockheight = wallet.getTransactions().values().stream().mapToInt(BlockTransactionHash::getHeight).min().orElse(wallet.getStoredBlockHeight());
specterWallet.descriptor = OutputDescriptor.getOutputDescriptor(wallet).toString(true);
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -83,6 +84,11 @@ public class SpecterDesktop implements WalletImport, WalletExport {
return true;
}
@Override
public boolean isWalletExportScannable() {
return true;
}
@Override
public String getName() {
return "Specter Desktop";
@@ -10,13 +10,14 @@ import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.MainApp;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
@@ -26,14 +27,18 @@ import java.security.SecureRandom;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.*;
import static com.sparrowwallet.drongo.crypto.Argon2KeyDeriver.SPRW1_PARAMETERS;
public class Storage {
private static final Logger log = LoggerFactory.getLogger(Storage.class);
public static final ECKey NO_PASSWORD_KEY = ECKey.fromPublicOnly(ECKey.fromPrivate(Utils.hexToBytes("885e5a09708a167ea356a252387aa7c4893d138d632e296df8fbf5c12798bd28")));
private static final DateFormat BACKUP_DATE_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss");
private static final Pattern DATE_PATTERN = Pattern.compile(".+-([0-9]{14}?).*");
public static final String SPARROW_DIR = ".sparrow";
public static final String WINDOWS_SPARROW_DIR = "Sparrow";
@@ -41,6 +46,7 @@ public class Storage {
public static final String WALLETS_BACKUP_DIR = "backup";
public static final String HEADER_MAGIC_1 = "SPRW1";
private static final int BINARY_HEADER_LENGTH = 28;
public static final String TEMP_BACKUP_EXTENSION = "tmp";
private File walletFile;
private final Gson gson;
@@ -81,17 +87,49 @@ public class Storage {
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
public Wallet loadWallet() throws IOException {
Reader reader = new FileReader(walletFile);
public WalletBackupAndKey loadWallet() throws IOException {
Wallet wallet = loadWallet(walletFile);
Wallet backupWallet = null;
File[] backups = getBackups("json." + TEMP_BACKUP_EXTENSION);
if(backups.length > 0) {
try {
backupWallet = loadWallet(backups[0]);
} catch(Exception e) {
log.error("Error loading backup wallet " + TEMP_BACKUP_EXTENSION, e);
}
}
encryptionPubKey = NO_PASSWORD_KEY;
return new WalletBackupAndKey(wallet, backupWallet, null);
}
public Wallet loadWallet(File jsonFile) throws IOException {
Reader reader = new FileReader(jsonFile);
Wallet wallet = gson.fromJson(reader, Wallet.class);
reader.close();
encryptionPubKey = NO_PASSWORD_KEY;
return wallet;
}
public WalletAndKey loadWallet(CharSequence password) throws IOException, StorageException {
InputStream fileStream = new FileInputStream(walletFile);
public WalletBackupAndKey loadWallet(CharSequence password) throws IOException, StorageException {
WalletAndKey walletAndKey = loadWallet(walletFile, password);
WalletAndKey backupAndKey = new WalletAndKey(null, null);
File[] backups = getBackups(TEMP_BACKUP_EXTENSION, "json." + TEMP_BACKUP_EXTENSION);
if(backups.length > 0) {
try {
backupAndKey = loadWallet(backups[0], password);
} catch(Exception e) {
log.error("Error loading backup wallet " + TEMP_BACKUP_EXTENSION, e);
}
}
return new WalletBackupAndKey(walletAndKey.wallet, backupAndKey.wallet, walletAndKey.key);
}
public WalletAndKey loadWallet(File encryptedFile, CharSequence password) throws IOException, StorageException {
InputStream fileStream = new FileInputStream(encryptedFile);
ECKey encryptionKey = getEncryptionKey(password, fileStream);
InputStream inputStream = new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic()));
@@ -168,6 +206,18 @@ public class Storage {
}
public void backupWallet() throws IOException {
backupWallet(null);
}
public void backupTempWallet() {
try {
backupWallet(TEMP_BACKUP_EXTENSION);
} catch(IOException e) {
log.error("Error creating ." + TEMP_BACKUP_EXTENSION + " backup wallet", e);
}
}
public void backupWallet(String extension) throws IOException {
File backupDir = getWalletsBackupDir();
Date backupDate = new Date();
@@ -180,10 +230,52 @@ public class Storage {
backupName += dateSuffix;
}
if(extension != null) {
backupName += "." + extension;
}
File backupFile = new File(backupDir, backupName);
Files.copy(walletFile, backupFile);
}
public void deleteBackups() {
deleteBackups(null);
}
public void deleteBackups(String extension) {
File[] backups = getBackups(extension);
for(File backup : backups) {
backup.delete();
}
}
private File[] getBackups(String extension) {
return getBackups(extension, null);
}
private File[] getBackups(String extension, String notExtension) {
File backupDir = getWalletsBackupDir();
File[] backups = backupDir.listFiles((dir, name) -> {
return name.startsWith(Files.getNameWithoutExtension(walletFile.getName()) + "-") &&
getBackupDate(name) != null &&
(extension == null || name.endsWith("." + extension)) &&
(notExtension == null || !name.endsWith("." + notExtension));
});
Arrays.sort(backups, Comparator.comparing(o -> getBackupDate(((File)o).getName())).reversed());
return backups;
}
private String getBackupDate(String backupFileName) {
Matcher matcher = DATE_PATTERN.matcher(backupFileName);
if(matcher.matches()) {
return matcher.group(1);
}
return null;
}
public ECKey getEncryptionPubKey() {
return encryptionPubKey;
}
@@ -238,6 +330,8 @@ public class Storage {
}
keyDeriver = new Argon2KeyDeriver(salt);
} else if(inputStream != null) {
inputStream.skip(BINARY_HEADER_LENGTH);
}
return keyDeriver;
@@ -465,7 +559,16 @@ public class Storage {
}
}
public static class LoadWalletService extends Service<WalletAndKey> {
public static class WalletBackupAndKey extends WalletAndKey {
public final Wallet backupWallet;
public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, Key key) {
super(wallet, key);
this.backupWallet = backupWallet;
}
}
public static class LoadWalletService extends Service<WalletBackupAndKey> {
private final Storage storage;
private final SecureString password;
@@ -475,12 +578,12 @@ public class Storage {
}
@Override
protected Task<WalletAndKey> createTask() {
protected Task<WalletBackupAndKey> createTask() {
return new Task<>() {
protected WalletAndKey call() throws IOException, StorageException, MnemonicException {
WalletAndKey walletAndKey = storage.loadWallet(password);
protected WalletBackupAndKey call() throws IOException, StorageException {
WalletBackupAndKey walletBackupAndKey = storage.loadWallet(password);
password.clear();
return walletAndKey;
return walletBackupAndKey;
}
};
}
@@ -8,4 +8,5 @@ public interface WalletExport extends Export {
void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException;
String getWalletExportDescription();
String getExportFileExtension();
boolean isWalletExportScannable();
}
@@ -16,9 +16,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
public void initializeView() {
List<KeystoreFileImport> importers = Collections.emptyList();
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new SpecterDIY());
importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig(), new SpecterDIY());
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new SpecterDIY());
importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new PassportMultisig(), new SpecterDIY());
}
for(KeystoreImport importer : importers) {
@@ -25,23 +25,29 @@ import java.util.stream.Collectors;
public class Bwt {
private static final Logger log = LoggerFactory.getLogger(Bwt.class);
public static final String DEFAULT_CORE_WALLET = "sparrow";
private static final int IMPORT_BATCH_SIZE = 350;
private static boolean initialized;
private Long shutdownPtr;
private boolean terminating;
private boolean ready;
static {
try {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
NativeUtils.loadLibraryFromJar("/native/osx/x64/libbwt_jni.dylib");
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
NativeUtils.loadLibraryFromJar("/native/windows/x64/bwt_jni.dll");
} else {
NativeUtils.loadLibraryFromJar("/native/linux/x64/libbwt_jni.so");
public synchronized static void initialize() {
if(!initialized) {
try {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
NativeUtils.loadLibraryFromJar("/native/osx/x64/libbwt_jni.dylib");
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
NativeUtils.loadLibraryFromJar("/native/windows/x64/bwt_jni.dll");
} else {
NativeUtils.loadLibraryFromJar("/native/linux/x64/libbwt_jni.so");
}
initialized = true;
} catch(IOException e) {
log.error("Error loading bwt library", e);
}
} catch(IOException e) {
log.error("Error loading bwt library", e);
}
}
@@ -106,14 +112,15 @@ public class Bwt {
Config config = Config.get();
bwtConfig.bitcoindUrl = config.getCoreServer();
if(config.getCoreAuthType() == CoreAuthType.COOKIE) {
if(config.getCoreAuthType() == CoreAuthType.COOKIE && config.getCoreDataDir() != null) {
bwtConfig.bitcoindDir = config.getCoreDataDir().getAbsolutePath() + "/";
} else {
bwtConfig.bitcoindAuth = config.getCoreAuth();
}
if(config.getCoreWallet() != null && !config.getCoreWallet().isEmpty()) {
if(config.getCoreMultiWallet() != Boolean.FALSE) {
bwtConfig.bitcoindWallet = config.getCoreWallet();
}
bwtConfig.createWalletIfMissing = true;
Gson gson = new Gson();
String jsonConfig = gson.toJson(bwtConfig);
@@ -136,7 +143,6 @@ public class Bwt {
this.terminating = false;
this.ready = false;
this.shutdownPtr = null;
Platform.runLater(() -> EventManager.get().post(new BwtShutdownEvent()));
}
public boolean isRunning() {
@@ -178,6 +184,9 @@ public class Bwt {
@SerializedName("bitcoind_wallet")
public String bitcoindWallet;
@SerializedName("create_wallet_if_missing")
public Boolean createWalletIfMissing;
@SerializedName("descriptors")
public List<String> descriptors;
@@ -53,7 +53,10 @@ public class ElectrumServer {
File electrumServerCert = null;
String proxyServer = null;
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
electrumServer = Config.get().getPublicElectrumServer();
proxyServer = Config.get().getProxyServer();
} else if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
if(bwtElectrumServer == null) {
throw new ServerConfigException("Could not connect to Bitcoin Core RPC");
}
@@ -629,6 +632,9 @@ public class ElectrumServer {
Map<Integer, Double> targetBlocksFeeRatesSats = new TreeMap<>();
for(Integer target : targetBlocksFeeRatesBtcKb.keySet()) {
long minFeeRateSatsKb = (long)(targetBlocksFeeRatesBtcKb.get(target) * Transaction.SATOSHIS_PER_BITCOIN);
if(minFeeRateSatsKb < 0) {
minFeeRateSatsKb = 1000;
}
targetBlocksFeeRatesSats.put(target, minFeeRateSatsKb / 1000d);
}
@@ -779,7 +785,7 @@ public class ElectrumServer {
private final Bwt bwt = new Bwt();
private final ReentrantLock bwtStartLock = new ReentrantLock();
private final Condition bwtStartCondition = bwtStartLock.newCondition();
private final StringProperty statusProperty = new SimpleStringProperty();
private Throwable bwtStartException;
public ConnectionService() {
this(true);
@@ -796,10 +802,14 @@ public class ElectrumServer {
ElectrumServer electrumServer = new ElectrumServer();
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
Bwt.initialize();
if(!bwt.isRunning()) {
Bwt.ConnectionService bwtConnectionService = bwt.getConnectionService(subscribe ? AppServices.get().getOpenWallets().keySet() : null);
bwtStartException = null;
bwtConnectionService.setOnFailed(workerStateEvent -> {
log.error("Failed to start BWT", workerStateEvent.getSource().getException());
bwtStartException = workerStateEvent.getSource().getException();
try {
bwtStartLock.lock();
bwtStartCondition.signal();
@@ -813,8 +823,12 @@ public class ElectrumServer {
bwtStartLock.lock();
bwtStartCondition.await();
if(!bwt.isRunning()) {
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
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.");
}
}
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
@@ -921,13 +935,16 @@ public class ElectrumServer {
Bwt.DisconnectionService disconnectionService = bwt.getDisconnectionService();
disconnectionService.setOnSucceeded(workerStateEvent -> {
ElectrumServer.bwtElectrumServer = null;
if(subscribe) {
EventManager.get().post(new BwtShutdownEvent());
}
});
disconnectionService.setOnFailed(workerStateEvent -> {
log.error("Failed to stop BWT", workerStateEvent.getSource().getException());
});
Platform.runLater(disconnectionService::start);
} else {
Platform.runLater(() -> EventManager.get().post(new DisconnectionEvent()));
disconnectionService.start();
} else if(subscribe) {
EventManager.get().post(new DisconnectionEvent());
}
}
@@ -942,11 +959,6 @@ public class ElectrumServer {
log.error("Uncaught error in ConnectionService", e);
}
@Subscribe
public void torStatus(TorStatusEvent event) {
statusProperty.set(event.getStatus());
}
@Subscribe
public void bwtElectrumReadyStatus(BwtElectrumReadyStatusEvent event) {
if(this.isRunning()) {
@@ -975,10 +987,6 @@ public class ElectrumServer {
bwtStartLock.unlock();
}
}
public StringProperty statusProperty() {
return statusProperty;
}
}
public static class ReadRunnable implements Runnable {
@@ -1,9 +1,8 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.google.gson.Gson;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.io.Config;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@@ -13,7 +12,6 @@ import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
@@ -52,7 +50,7 @@ public enum ExchangeSource {
private CoinbaseRates getRates() {
String url = "https://api.coinbase.com/v2/exchange-rates?currency=BTC";
Proxy proxy = getProxy();
Proxy proxy = AppServices.getProxy();
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();
@@ -83,7 +81,7 @@ public enum ExchangeSource {
private CoinGeckoRates getRates() {
String url = "https://api.coingecko.com/api/v3/exchange_rates";
Proxy proxy = getProxy();
Proxy proxy = AppServices.getProxy();
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();
@@ -116,17 +114,6 @@ public enum ExchangeSource {
}
}
private static Proxy getProxy() {
Config config = Config.get();
if(config.isUseProxy()) {
HostAndPort proxy = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
}
return null;
}
@Override
public String toString() {
return name;
@@ -40,6 +40,9 @@ public enum FeeRatesSource {
};
private static final Logger log = LoggerFactory.getLogger(FeeRatesSource.class);
public static final int BLOCKS_IN_HALF_HOUR = 3;
public static final int BLOCKS_IN_HOUR = 6;
public static final int BLOCKS_IN_TWO_HOURS = 12;
private final String name;
@@ -61,16 +64,20 @@ public enum FeeRatesSource {
Gson gson = new Gson();
ThreeTierRates threeTierRates = gson.fromJson(reader, ThreeTierRates.class);
for(Integer blockTarget : defaultblockTargetFeeRates.keySet()) {
if(blockTarget < 3) {
if(blockTarget < BLOCKS_IN_HALF_HOUR) {
blockTargetFeeRates.put(blockTarget, threeTierRates.fastestFee);
} else if(blockTarget < 6) {
} else if(blockTarget < BLOCKS_IN_HOUR) {
blockTargetFeeRates.put(blockTarget, threeTierRates.halfHourFee);
} else if(blockTarget <= 10 || defaultblockTargetFeeRates.get(blockTarget) > threeTierRates.hourFee) {
} else if(blockTarget < BLOCKS_IN_TWO_HOURS || defaultblockTargetFeeRates.get(blockTarget) > threeTierRates.hourFee) {
blockTargetFeeRates.put(blockTarget, threeTierRates.hourFee);
} else {
blockTargetFeeRates.put(blockTarget, defaultblockTargetFeeRates.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);
}
@@ -98,5 +105,6 @@ public enum FeeRatesSource {
Double fastestFee;
Double halfHourFee;
Double hourFee;
Double minimumFee;
}
}
@@ -28,12 +28,13 @@ public enum Protocol {
@Override
public Transport getTransport(HostAndPort server, HostAndPort proxy) {
throw new UnsupportedOperationException("TCP protocol does not support proxying");
//Avoid using a TorSocket if a proxy is specified, even if a .onion address
return new TcpTransport(server, proxy);
}
@Override
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) {
throw new UnsupportedOperationException("TCP protocol does not support proxying");
return getTransport(server, proxy);
}
},
SSL {
@@ -116,7 +117,7 @@ public enum Protocol {
}
public boolean isOnionAddress(HostAndPort server) {
return server.getHost().toLowerCase().endsWith(".onion");
return server.getHost().toLowerCase().endsWith(TorService.TOR_ADDRESS_SUFFIX);
}
public static Protocol getProtocol(String url) {
@@ -0,0 +1,67 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import javax.net.SocketFactory;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.Socket;
import static com.sparrowwallet.sparrow.net.ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT;
public class ProxySocketFactory extends SocketFactory {
private final Proxy proxy;
public ProxySocketFactory() {
this(Proxy.NO_PROXY);
}
public ProxySocketFactory(HostAndPort proxyHostAndPort) {
this(getSocksProxy(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(DEFAULT_PROXY_PORT)));
}
public ProxySocketFactory(Proxy proxy) {
this.proxy = proxy;
}
@Override
public Socket createSocket() {
return new Socket(proxy);
}
@Override
public Socket createSocket(String address, int port) throws IOException {
return createSocket(new InetSocketAddress(address, port), null);
}
@Override
public Socket createSocket(String address, int port, InetAddress localAddress, int localPort) throws IOException {
return createSocket(new InetSocketAddress(address, port), new InetSocketAddress(localAddress, localPort));
}
@Override
public Socket createSocket(InetAddress address, int port) throws IOException {
return createSocket(new InetSocketAddress(address, port), null);
}
@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {
return createSocket(new InetSocketAddress(address, port), new InetSocketAddress(localAddress, localPort));
}
private Socket createSocket(InetSocketAddress address, InetSocketAddress bindAddress) throws IOException {
Socket socket = new Socket(proxy);
if(bindAddress != null) {
socket.bind(bindAddress);
}
socket.connect(address);
return socket;
}
private static Proxy getSocksProxy(String proxyAddress, int proxyPort) {
return new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(proxyAddress, proxyPort));
}
}
@@ -0,0 +1,40 @@
package com.sparrowwallet.sparrow.net;
public enum PublicElectrumServer {
BLOCKSTREAM_INFO("blockstream.info", "ssl://blockstream.info:700"),
ELECTRUM_BLOCKSTREAM_INFO("electrum.blockstream.info", "ssl://electrum.blockstream.info:50002"),
LUKECHILDS_CO("bitcoin.lukechilds.co", "ssl://bitcoin.lukechilds.co:50002"),
EMZY_DE("electrum.emzy.de", "ssl://electrum.emzy.de:50002"),
BITAROO_NET("electrum.bitaroo.net", "ssl://electrum.bitaroo.net:50002");
PublicElectrumServer(String name, String url) {
this.name = name;
this.url = url;
}
private final String name;
private final String url;
public String getName() {
return name;
}
public String getUrl() {
return url;
}
public static PublicElectrumServer fromUrl(String url) {
for(PublicElectrumServer server : values()) {
if(server.url.equals(url)) {
return server;
}
}
return null;
}
@Override
public String toString() {
return name;
}
}
@@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.net;
public enum ServerType {
BITCOIN_CORE("Bitcoin Core"), ELECTRUM_SERVER("Electrum Server");
BITCOIN_CORE("Bitcoin Core"), ELECTRUM_SERVER("Private Electrum"), PUBLIC_ELECTRUM_SERVER("Public Electrum");
private final String name;
@@ -32,10 +32,10 @@ public class SubscriptionService {
Set<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
if(existingStatuses == null) {
log.warn("Received script hash status update for unsubscribed script hash: " + scriptHash);
log.debug("Received script hash status update for unsubscribed script hash: " + scriptHash);
ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status);
} else if(existingStatuses.contains(status)) {
log.warn("Received script hash status update, but status has not changed");
log.debug("Received script hash status update, but status has not changed");
return;
} else {
String oldStatus = Iterables.getLast(existingStatuses);
@@ -47,8 +47,12 @@ public class TcpTransport implements Transport, Closeable {
private final Gson gson = new Gson();
public TcpTransport(HostAndPort server) {
this(server, null);
}
public TcpTransport(HostAndPort server, HostAndPort proxy) {
this.server = server;
this.socketFactory = SocketFactory.getDefault();
this.socketFactory = (proxy == null ? SocketFactory.getDefault() : new ProxySocketFactory(proxy));
}
@Override
@@ -0,0 +1,61 @@
package com.sparrowwallet.sparrow.net;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import net.freehaven.tor.control.TorControlError;
import org.berndpruenster.netlayer.tor.NativeTor;
import org.berndpruenster.netlayer.tor.Tor;
import org.berndpruenster.netlayer.tor.TorCtlException;
import org.berndpruenster.netlayer.tor.Torrc;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
/**
* Service to start internal Tor (including a Tor proxy running on localhost:9050)
*
* This is a ScheduledService to take advantage of the retry on failure behaviour
*/
public class TorService extends ScheduledService<NativeTor> {
private static final Logger log = LoggerFactory.getLogger(TorService.class);
public static final int PROXY_PORT = 9050;
public static final String TOR_DIR_PREFIX = "tor";
public static final String TOR_ADDRESS_SUFFIX = ".onion";
@Override
protected Task<NativeTor> createTask() {
return new Task<>() {
protected NativeTor call() throws IOException {
if(Tor.getDefault() == null) {
Path path = Files.createTempDirectory(TOR_DIR_PREFIX);
File torInstallDir = path.toFile();
torInstallDir.deleteOnExit();
try {
LinkedHashMap<String, String> torrcOptionsMap = new LinkedHashMap<>();
torrcOptionsMap.put("SocksPort", Integer.toString(PROXY_PORT));
torrcOptionsMap.put("DisableNetwork", "0");
Torrc override = new Torrc(torrcOptionsMap);
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());
} else {
throw new IOException("Failed to start Tor", e);
}
}
}
return null;
}
};
}
}
@@ -1,18 +1,11 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.StatusEvent;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import javafx.application.Platform;
import com.sparrowwallet.sparrow.AppServices;
import org.berndpruenster.netlayer.tor.*;
import java.io.*;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
public class TorTcpTransport extends TcpTransport {
public static final String TOR_DIR_PREFIX = "tor";
@@ -21,35 +14,16 @@ public class TorTcpTransport extends TcpTransport {
super(server);
}
public TorTcpTransport(HostAndPort server, HostAndPort proxy) {
super(server, proxy);
}
@Override
protected Socket createSocket() throws IOException {
if(Tor.getDefault() == null) {
Platform.runLater(() -> {
String status = "Starting Tor...";
EventManager.get().post(new TorStatusEvent(status));
});
Path path = Files.createTempDirectory(TOR_DIR_PREFIX);
File torInstallDir = path.toFile();
torInstallDir.deleteOnExit();
try {
LinkedHashMap<String, String> torrcOptionsMap = new LinkedHashMap<>();
torrcOptionsMap.put("DisableNetwork", "0");
Torrc override = new Torrc(torrcOptionsMap);
NativeTor nativeTor = new NativeTor(torInstallDir, Collections.emptyList(), override);
Tor.setDefault(nativeTor);
} catch(TorCtlException e) {
e.printStackTrace();
throw new IOException(e);
}
if(!AppServices.isTorRunning()) {
throw new IllegalStateException("Can't create Tor socket, Tor is not running");
}
Platform.runLater(() -> {
String status = "Tor running, connecting to " + server.toString() + "...";
EventManager.get().post(new TorStatusEvent(status));
});
return new TorSocket(server.getHost(), server.getPort(), "sparrow");
}
}
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.MainApp;
import com.sparrowwallet.sparrow.event.VersionUpdatedEvent;
import javafx.concurrent.ScheduledService;
@@ -15,6 +16,7 @@ import org.slf4j.LoggerFactory;
import javax.net.ssl.HttpsURLConnection;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.SignatureException;
@@ -44,7 +46,8 @@ public class VersionCheckService extends ScheduledService<VersionUpdatedEvent> {
private VersionCheck getVersionCheck() throws IOException {
URL url = new URL(VERSION_CHECK_URL);
HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();
Proxy proxy = AppServices.getProxy();
HttpsURLConnection conn = (HttpsURLConnection)(proxy == null ? url.openConnection() : url.openConnection(proxy));
try(InputStreamReader reader = new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)) {
Gson gson = new Gson();
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.*;
@@ -32,6 +33,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
@FXML
private ComboBox<ExchangeSource> exchangeSource;
@FXML
private UnlabeledToggleSwitch validateDerivationPaths;
@FXML
private UnlabeledToggleSwitch groupByAddress;
@@ -93,6 +97,12 @@ public class GeneralPreferencesController extends PreferencesDetailController {
updateCurrencies(exchangeSource.getSelectionModel().getSelectedItem());
validateDerivationPaths.setSelected(config.isValidateDerivationPaths());
validateDerivationPaths.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
config.setValidateDerivationPaths(newValue);
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!newValue));
});
groupByAddress.setSelected(config.isGroupByAddress());
includeMempoolChange.setSelected(config.isIncludeMempoolChange());
groupByAddress.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
@@ -46,7 +46,7 @@ public class PreferencesDialog extends Dialog<Boolean> {
dialogPane.getButtonTypes().addAll(newWalletButtonType);
}
dialogPane.setPrefWidth(650);
dialogPane.setPrefWidth(750);
dialogPane.setPrefHeight(600);
preferencesController.reconnectOnClosingProperty().set(AppServices.isConnecting() || AppServices.isConnected());
@@ -14,7 +14,6 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.concurrent.WorkerStateEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.text.Font;
@@ -22,6 +21,9 @@ import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
import net.freehaven.tor.control.TorControlError;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
@@ -40,6 +42,7 @@ import java.security.cert.CertificateFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Random;
public class ServerPreferencesController extends PreferencesDetailController {
private static final Logger log = LoggerFactory.getLogger(ServerPreferencesController.class);
@@ -47,6 +50,27 @@ public class ServerPreferencesController extends PreferencesDetailController {
@FXML
private ToggleGroup serverTypeToggleGroup;
@FXML
private SegmentedButton serverTypeSegmentedButton;
@FXML
private ToggleButton publicElectrumToggle;
@FXML
private Form publicElectrumForm;
@FXML
private ComboBox<PublicElectrumServer> publicElectrumServer;
@FXML
private UnlabeledToggleSwitch publicUseProxy;
@FXML
private TextField publicProxyHost;
@FXML
private TextField publicProxyPort;
@FXML
private Form coreForm;
@@ -121,8 +145,12 @@ public class ServerPreferencesController extends PreferencesDetailController {
private final ValidationSupport validationSupport = new ValidationSupport();
private TorService torService;
private ElectrumServer.ConnectionService connectionService;
private Boolean useSslOriginal;
@Override
public void initializeView(Config config) {
EventManager.get().register(this);
@@ -132,13 +160,15 @@ public class ServerPreferencesController extends PreferencesDetailController {
Platform.runLater(this::setupValidation);
publicElectrumForm.managedProperty().bind(publicElectrumForm.visibleProperty());
coreForm.managedProperty().bind(coreForm.visibleProperty());
electrumForm.managedProperty().bind(electrumForm.visibleProperty());
coreForm.visibleProperty().bind(electrumForm.visibleProperty().not());
serverTypeToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
if(serverTypeToggleGroup.getSelectedToggle() != null) {
ServerType existingType = config.getServerType();
ServerType serverType = (ServerType)newValue.getUserData();
publicElectrumForm.setVisible(serverType == ServerType.PUBLIC_ELECTRUM_SERVER);
coreForm.setVisible(serverType == ServerType.BITCOIN_CORE);
electrumForm.setVisible(serverType == ServerType.ELECTRUM_SERVER);
config.setServerType(serverType);
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE, ""));
@@ -150,12 +180,26 @@ public class ServerPreferencesController extends PreferencesDetailController {
oldValue.setSelected(true);
}
});
ServerType serverType = config.getServerType() != null ? config.getServerType() : (config.getCoreServer() == null && config.getElectrumServer() != null ? ServerType.ELECTRUM_SERVER : ServerType.BITCOIN_CORE);
ServerType serverType = config.getServerType() != null ?
(config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && Network.get() != Network.MAINNET ? ServerType.BITCOIN_CORE : config.getServerType()) :
(config.getCoreServer() == null && config.getElectrumServer() != null ? ServerType.ELECTRUM_SERVER :
(config.getCoreServer() != null || Network.get() != Network.MAINNET ? ServerType.BITCOIN_CORE : ServerType.PUBLIC_ELECTRUM_SERVER));
if(Network.get() != Network.MAINNET) {
serverTypeSegmentedButton.getButtons().remove(publicElectrumToggle);
serverTypeToggleGroup.getToggles().remove(publicElectrumToggle);
}
serverTypeToggleGroup.selectToggle(serverTypeToggleGroup.getToggles().stream().filter(toggle -> toggle.getUserData() == serverType).findFirst().orElse(null));
publicElectrumServer.getSelectionModel().selectedItemProperty().addListener(getPublicElectrumServerListener(config));
publicUseProxy.selectedProperty().bindBidirectional(useProxy.selectedProperty());
publicProxyHost.textProperty().bindBidirectional(proxyHost.textProperty());
publicProxyPort.textProperty().bindBidirectional(proxyPort.textProperty());
corePort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
electrumPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
proxyPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
publicProxyPort.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
coreHost.textProperty().addListener(getBitcoinCoreListener(config));
corePort.textProperty().addListener(getBitcoinCoreListener(config));
@@ -205,10 +249,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
});
coreMultiWallet.selectedProperty().addListener((observable, oldValue, newValue) -> {
coreWallet.setText(" ");
coreWallet.setText("");
config.setCoreMultiWallet(newValue);
coreWallet.setDisable(!newValue);
coreWallet.setPromptText(newValue ? "" : "Default");
});
electrumUseSsl.selectedProperty().addListener((observable, oldValue, newValue) -> {
@@ -244,17 +286,11 @@ public class ServerPreferencesController extends PreferencesDetailController {
proxyHost.setText(proxyHost.getText().trim());
proxyHost.setDisable(!newValue);
proxyPort.setDisable(!newValue);
if(newValue) {
electrumUseSsl.setSelected(true);
electrumUseSsl.setDisable(true);
} else {
electrumUseSsl.setDisable(false);
}
publicProxyHost.setDisable(!newValue);
publicProxyPort.setDisable(!newValue);
});
boolean isConnected = AppServices.isConnecting() || AppServices.isConnected();
setFieldsEditable(!isConnected);
if(AppServices.isConnecting()) {
testResults.appendText("Connecting to server, please wait...");
@@ -266,7 +302,12 @@ public class ServerPreferencesController extends PreferencesDetailController {
testConnection.setOnAction(event -> {
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
testResults.setText("Connecting to " + config.getServerAddress() + "...");
startElectrumConnection();
if(Config.get().requiresTor() && Tor.getDefault() == null) {
startTor();
} else {
startElectrumConnection();
}
});
editConnection.managedProperty().bind(editConnection.visibleProperty());
@@ -279,6 +320,13 @@ public class ServerPreferencesController extends PreferencesDetailController {
testConnection.setVisible(true);
});
PublicElectrumServer configPublicElectrumServer = PublicElectrumServer.fromUrl(config.getPublicElectrumServer());
if(configPublicElectrumServer == null) {
publicElectrumServer.setValue(PublicElectrumServer.values()[new Random().nextInt(PublicElectrumServer.values().length)]);
} else {
publicElectrumServer.setValue(configPublicElectrumServer);
}
String coreServer = config.getCoreServer();
if(coreServer != null) {
Protocol protocol = Protocol.getProtocol(coreServer);
@@ -307,12 +355,15 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
}
coreMultiWallet.setSelected(true);
coreMultiWallet.setSelected(config.getCoreWallet() != null);
if(config.getCoreWallet() != null) {
coreWallet.setPromptText("Default");
if(config.getCoreWallet() == null) {
coreWallet.setText(Bwt.DEFAULT_CORE_WALLET);
} else {
coreWallet.setText(config.getCoreWallet());
}
coreMultiWallet.setSelected(config.getCoreMultiWallet() != Boolean.FALSE);
String electrumServer = config.getElectrumServer();
if(electrumServer != null) {
Protocol protocol = Protocol.getProtocol(electrumServer);
@@ -339,11 +390,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
useProxy.setSelected(config.isUseProxy());
proxyHost.setDisable(!config.isUseProxy());
proxyPort.setDisable(!config.isUseProxy());
if(config.isUseProxy()) {
electrumUseSsl.setSelected(true);
electrumUseSsl.setDisable(true);
}
publicProxyHost.setDisable(!config.isUseProxy());
publicProxyPort.setDisable(!config.isUseProxy());
String proxyServer = config.getProxyServer();
if(proxyServer != null) {
@@ -353,6 +401,32 @@ public class ServerPreferencesController extends PreferencesDetailController {
proxyPort.setText(Integer.toString(server.getPort()));
}
}
setFieldsEditable(!isConnected);
}
private void startTor() {
if(torService != null && torService.isRunning()) {
return;
}
torService = new TorService();
torService.setPeriod(Duration.hours(1000));
torService.setRestartOnFailure(false);
torService.setOnSucceeded(workerStateEvent -> {
Tor.setDefault(torService.getValue());
torService.cancel();
testResults.appendText("\nTor running, connecting to " + Config.get().getServerAddress() + "...");
startElectrumConnection();
});
torService.setOnFailed(workerStateEvent -> {
torService.cancel();
testResults.appendText("\nTor failed to start");
showConnectionFailure(workerStateEvent.getSource().getException());
});
torService.start();
}
private void startElectrumConnection() {
@@ -362,10 +436,10 @@ public class ServerPreferencesController extends PreferencesDetailController {
connectionService = new ElectrumServer.ConnectionService(false);
connectionService.setPeriod(Duration.hours(1));
connectionService.setRestartOnFailure(false);
EventManager.get().register(connectionService);
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
testResults.setText(testResults.getText() + "\n" + newValue);
});
useSslOriginal = null;
connectionService.setOnSucceeded(successEvent -> {
EventManager.get().unregister(connectionService);
@@ -377,8 +451,30 @@ public class ServerPreferencesController extends PreferencesDetailController {
});
connectionService.setOnFailed(workerStateEvent -> {
EventManager.get().unregister(connectionService);
showConnectionFailure(workerStateEvent);
showConnectionFailure(workerStateEvent.getSource().getException());
connectionService.cancel();
if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
if(useSslOriginal == null) {
Integer portAsInteger = getPort(electrumPort.getText());
if(!electrumUseSsl.isSelected() && portAsInteger != null && portAsInteger == TcpOverTlsTransport.DEFAULT_PORT) {
useSslOriginal = false;
electrumUseSsl.setSelected(true);
} else if(electrumUseSsl.isSelected() && portAsInteger != null && portAsInteger == TcpTransport.DEFAULT_PORT) {
useSslOriginal = true;
electrumUseSsl.setSelected(false);
}
if(useSslOriginal != null) {
EventManager.get().register(connectionService);
connectionService.reset();
connectionService.start();
}
} else {
electrumUseSsl.setSelected(useSslOriginal);
useSslOriginal = null;
}
}
});
connectionService.start();
}
@@ -386,6 +482,11 @@ public class ServerPreferencesController extends PreferencesDetailController {
private void setFieldsEditable(boolean editable) {
serverTypeToggleGroup.getToggles().forEach(toggle -> ((ToggleButton)toggle).setDisable(!editable));
publicElectrumServer.setDisable(!editable);
publicUseProxy.setDisable(!editable);
publicProxyHost.setDisable(!editable);
publicProxyPort.setDisable(!editable);
coreHost.setDisable(!editable);
corePort.setDisable(!editable);
coreAuthToggleGroup.getToggles().forEach(toggle -> ((ToggleButton)toggle).setDisable(!editable));
@@ -419,19 +520,30 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
}
private void showConnectionFailure(WorkerStateEvent failEvent) {
Throwable e = failEvent.getSource().getException();
log.error("Connection error", e);
String reason = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
if(e.getCause() != null && e.getCause() instanceof SSLHandshakeException) {
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")) {
reason += "\nIs a Tor proxy already running on port " + TorService.PROXY_PORT + "?";
}
testResults.setText("Could not connect:\n\n" + reason);
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.EXCLAMATION_CIRCLE, "failure"));
}
private void setupValidation() {
validationSupport.registerValidator(publicProxyHost, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Proxy host required", publicUseProxy.isSelected() && newValue.isEmpty()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid host name", getHost(newValue) == null)
));
validationSupport.registerValidator(publicProxyPort, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid proxy port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
));
validationSupport.registerValidator(coreHost, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Core host", getHost(newValue) == null)
));
@@ -452,10 +564,6 @@ public class ServerPreferencesController extends PreferencesDetailController {
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Core pass required", coreAuthToggleGroup.getSelectedToggle().getUserData() == CoreAuthType.USERPASS && newValue.isEmpty())
));
validationSupport.registerValidator(coreWallet, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Core wallet required", coreMultiWallet.isSelected() && newValue.isEmpty())
));
validationSupport.registerValidator(electrumHost, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Electrum host", getHost(newValue) == null)
));
@@ -480,6 +588,13 @@ public class ServerPreferencesController extends PreferencesDetailController {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
}
@NotNull
private ChangeListener<PublicElectrumServer> getPublicElectrumServerListener(Config config) {
return (observable, oldValue, newValue) -> {
config.setPublicElectrumServer(newValue.getUrl());
};
}
@NotNull
private ChangeListener<String> getBitcoinCoreListener(Config config) {
return (observable, oldValue, newValue) -> {
@@ -649,4 +764,13 @@ public class ServerPreferencesController extends PreferencesDetailController {
connectionService.cancel();
}
}
@Subscribe
public void torStatus(TorStatusEvent event) {
Platform.runLater(() -> {
if(torService != null && torService.isRunning()) {
testResults.appendText("\n" + event.getStatus());
}
});
}
}
@@ -29,6 +29,7 @@ import javafx.collections.ObservableMap;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
@@ -451,7 +452,7 @@ public class HeadersController extends TransactionFormController implements Init
private void updateSize() {
size.setText(headersForm.getTransaction().getSize() + " B");
virtualSize.setText(headersForm.getTransaction().getVirtualSize() + " vB");
virtualSize.setText(String.format("%.2f", headersForm.getTransaction().getVirtualSize()) + " vB");
}
private Long calculateFee(Map<Sha256Hash, BlockTransaction> inputTransactions) {
@@ -599,11 +600,21 @@ public class HeadersController extends TransactionFormController implements Init
private void updateTxId() {
id.setText(headersForm.getTransaction().calculateTxId(false).toString());
if(!headersForm.isTransactionFinalized()) {
if(!id.getStyleClass().contains(UNFINALIZED_TXID_CLASS)) {
id.getStyleClass().add(UNFINALIZED_TXID_CLASS);
}
addStyleClass(id, UNFINALIZED_TXID_CLASS);
addStyleClass(size, UNFINALIZED_TXID_CLASS);
addStyleClass(virtualSize, UNFINALIZED_TXID_CLASS);
addStyleClass(feeRate, UNFINALIZED_TXID_CLASS);
} else {
id.getStyleClass().remove(UNFINALIZED_TXID_CLASS);
size.getStyleClass().remove(UNFINALIZED_TXID_CLASS);
virtualSize.getStyleClass().remove(UNFINALIZED_TXID_CLASS);
feeRate.getStyleClass().remove(UNFINALIZED_TXID_CLASS);
}
}
private void addStyleClass(Node node, String styleClass) {
if(!node.getStyleClass().contains(styleClass)) {
node.getStyleClass().add(styleClass);
}
}
@@ -633,7 +644,7 @@ public class HeadersController extends TransactionFormController implements Init
toggleButton.setSelected(false);
//TODO: Remove once Cobo Vault has upgraded to UR2.0
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT));
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT) || keystore.getWalletModel().equals(WalletModel.SPARROW));
try {
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(RegistryType.CRYPTO_PSBT.toString(), headersForm.getPsbt().serialize(), addLegacyEncodingOption);
@@ -665,6 +676,10 @@ public class HeadersController extends TransactionFormController implements Init
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!file.getName().toLowerCase().endsWith(".psbt")) {
file = new File(file.getAbsolutePath() + ".psbt");
}
try {
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
writer.print(headersForm.getPsbt().toBase64String());
@@ -64,6 +64,7 @@ public class TransactionController implements Initializable {
public void initializeView() {
initializeTxTree();
transactionMasterDetail.setDividerPosition(0.82);
transactionMasterDetail.setShowDetailNode(Config.get().isShowTransactionHex());
txhex.setTransaction(getTransaction());
highlightTxHex();
@@ -75,6 +76,12 @@ public class TransactionController implements Initializable {
} else if(TransactionView.OUTPUT.equals(initialView) && initialIndex >= PageForm.PAGE_SIZE) {
fetchOutputBlockTransactions(initialIndex, initialIndex + 1);
}
transactionMasterDetail.sceneProperty().addListener((observable, oldScene, newScene) -> {
if(oldScene == null && newScene != null) {
transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.82);
}
});
}
private void initializeTxTree() {
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
@@ -11,10 +12,11 @@ import org.controlsfx.tools.Borders;
import java.io.IOException;
public class AdvancedDialog extends Dialog<Void> {
public AdvancedDialog(Wallet wallet) {
public class AdvancedDialog extends Dialog<Boolean> {
public AdvancedDialog(WalletForm walletForm) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
Wallet wallet = walletForm.getWallet();
try {
FXMLLoader advancedLoader = new FXMLLoader(AppServices.class.getResource("wallet/advanced.fxml"));
@@ -22,11 +24,18 @@ public class AdvancedDialog extends Dialog<Void> {
AdvancedController settingsAdvancedController = advancedLoader.getController();
settingsAdvancedController.initializeView(wallet);
boolean noPassword = Storage.NO_PASSWORD_KEY.equals(walletForm.getStorage().getEncryptionPubKey());
final ButtonType closeButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(closeButtonType);
final ButtonType passwordButtonType = new javafx.scene.control.ButtonType(noPassword ? "Add Password..." : "Change Password...", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(closeButtonType);
if(wallet.isValid()) {
dialogPane.getButtonTypes().add(passwordButtonType);
}
dialogPane.setPrefWidth(400);
dialogPane.setPrefHeight(300);
setResultConverter(dialogButton -> dialogButton == passwordButtonType);
}
catch(IOException e) {
throw new RuntimeException(e);
@@ -60,7 +60,7 @@ public class PaymentController extends WalletFormController implements Initializ
private FiatLabel fiatAmount;
@FXML
private Button maxButton;
private ToggleButton maxButton;
@FXML
private Button addPaymentButton;
@@ -72,6 +72,11 @@ public class PaymentController extends WalletFormController implements Initializ
sendController.utxoSelectorProperty().setValue(null);
}
for(Tab tab : sendController.getPaymentTabs().getTabs()) {
PaymentController controller = (PaymentController) tab.getUserData();
controller.setSendMax(false);
}
Long recipientValueSats = getRecipientValueSats();
if(recipientValueSats != null) {
setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), recipientValueSats);
@@ -235,7 +240,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
public Payment getPayment() {
return getPayment(false);
return getPayment(isSendMax());
}
public Payment getPayment(boolean sendAll) {
@@ -273,6 +278,7 @@ public class PaymentController extends WalletFormController implements Initializ
amount.textProperty().addListener(amountListener);
fiatAmount.setText("");
setSendMax(false);
}
public void setMaxInput(ActionEvent event) {
@@ -287,9 +293,11 @@ public class PaymentController extends WalletFormController implements Initializ
for(Tab tab : sendController.getPaymentTabs().getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
if(controller != this) {
controller.setSendMax(false);
payments.add(controller.getPayment());
} else {
payments.add(getPayment(true));
setSendMax(true);
payments.add(getPayment());
}
}
sendController.updateTransaction(payments);
@@ -318,6 +326,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
if(bitcoinURI.getAmount() != null) {
setRecipientValueSats(bitcoinURI.getAmount());
setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), bitcoinURI.getAmount());
}
if(bitcoinURI.getPayjoinUrl() != null) {
AppServices.addPayjoinURI(bitcoinURI);
@@ -333,6 +342,14 @@ public class PaymentController extends WalletFormController implements Initializ
return addPaymentButton;
}
public boolean isSendMax() {
return maxButton.isSelected();
}
public void setSendMax(boolean sendMax) {
maxButton.setSelected(sendMax);
}
@Subscribe
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
BitcoinUnit unit = sendController.getBitcoinUnit(event.getBitcoinUnit());
@@ -9,7 +9,7 @@ import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
@@ -17,10 +17,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.ReceiveToEvent;
import com.sparrowwallet.sparrow.event.UsbDeviceEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryChangedEvent;
import com.sparrowwallet.sparrow.event.WalletNodesChangedEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
@@ -153,26 +150,26 @@ public class ReceiveController extends WalletFormController implements Initializ
}
private void updateDisplayAddress(List<Device> devices) {
//Can only display address for single sig wallets. See https://github.com/bitcoin-core/HWI/issues/224
Wallet wallet = getWalletForm().getWallet();
if(wallet.getPolicyType().equals(PolicyType.SINGLE)) {
List<Device> addressDevices = devices.stream().filter(device -> wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint().equals(device.getFingerprint())).collect(Collectors.toList());
if(addressDevices.isEmpty()) {
addressDevices = devices.stream().filter(device -> device.getNeedsPinSent() || device.getNeedsPassphraseSent()).collect(Collectors.toList());
}
OutputDescriptor walletDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
List<String> walletFingerprints = walletDescriptor.getExtendedPublicKeys().stream().map(extKey -> walletDescriptor.getKeyDerivation(extKey).getMasterFingerprint()).collect(Collectors.toList());
if(!addressDevices.isEmpty()) {
if(currentEntry != null) {
displayAddress.setVisible(true);
}
List<Device> addressDevices = devices.stream().filter(device -> walletFingerprints.contains(device.getFingerprint())).collect(Collectors.toList());
if(addressDevices.isEmpty()) {
addressDevices = devices.stream().filter(device -> device.getNeedsPinSent() || device.getNeedsPassphraseSent()).collect(Collectors.toList());
}
displayAddress.setUserData(addressDevices);
return;
} else if(currentEntry != null && wallet.getKeystores().stream().anyMatch(keystore -> keystore.getSource().equals(KeystoreSource.HW_USB))) {
if(!addressDevices.isEmpty()) {
if(currentEntry != null) {
displayAddress.setVisible(true);
displayAddress.setUserData(null);
return;
}
displayAddress.setUserData(addressDevices);
return;
} else if(currentEntry != null && wallet.getKeystores().stream().anyMatch(keystore -> keystore.getSource().equals(KeystoreSource.HW_USB))) {
displayAddress.setVisible(true);
displayAddress.setUserData(null);
return;
}
displayAddress.setVisible(false);
@@ -204,28 +201,27 @@ public class ReceiveController extends WalletFormController implements Initializ
@SuppressWarnings("unchecked")
public void displayAddress(ActionEvent event) {
Wallet wallet = getWalletForm().getWallet();
if(wallet.getPolicyType() == PolicyType.SINGLE && currentEntry != null) {
Keystore keystore = wallet.getKeystores().get(0);
KeyDerivation fullDerivation = keystore.getKeyDerivation().extend(currentEntry.getNode().getDerivation());
if(currentEntry != null) {
OutputDescriptor addressDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet(), currentEntry.getNode().getKeyPurpose(), currentEntry.getNode().getIndex());
List<Device> possibleDevices = (List<Device>)displayAddress.getUserData();
if(possibleDevices != null && !possibleDevices.isEmpty()) {
if(possibleDevices.size() > 1 || possibleDevices.get(0).getNeedsPinSent() || possibleDevices.get(0).getNeedsPassphraseSent()) {
DeviceAddressDialog dlg = new DeviceAddressDialog(List.of(keystore.getKeyDerivation().getMasterFingerprint()), wallet, fullDerivation);
DeviceAddressDialog dlg = new DeviceAddressDialog(wallet, addressDescriptor);
dlg.showAndWait();
} else {
Device actualDevice = possibleDevices.get(0);
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(actualDevice, "", wallet.getScriptType(), fullDerivation.getDerivationPath());
Hwi.DisplayAddressService displayAddressService = new Hwi.DisplayAddressService(actualDevice, "", wallet.getScriptType(), addressDescriptor);
displayAddressService.setOnFailed(failedEvent -> {
Platform.runLater(() -> {
DeviceAddressDialog dlg = new DeviceAddressDialog(List.of(keystore.getKeyDerivation().getMasterFingerprint()), wallet, fullDerivation);
DeviceAddressDialog dlg = new DeviceAddressDialog(wallet, addressDescriptor);
dlg.showAndWait();
});
});
displayAddressService.start();
}
} else {
DeviceAddressDialog dlg = new DeviceAddressDialog(List.of(keystore.getKeyDerivation().getMasterFingerprint()), wallet, fullDerivation);
DeviceAddressDialog dlg = new DeviceAddressDialog(wallet, addressDescriptor);
dlg.showAndWait();
}
}
@@ -261,6 +257,11 @@ public class ReceiveController extends WalletFormController implements Initializ
return duplicateGlyph;
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
displayAddress.setUserData(null);
}
@Subscribe
public void receiveTo(ReceiveToEvent event) {
if(event.getReceiveEntry().getWallet().equals(getWalletForm().getWallet())) {
@@ -11,9 +11,11 @@ import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.property.*;
@@ -28,6 +30,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
@@ -79,6 +82,12 @@ public class SendController extends WalletFormController implements Initializabl
@FXML
private CopyableLabel feeRate;
@FXML
private Label feeRatePriority;
@FXML
private Glyph feeRatePriorityGlyph;
@FXML
private TextField fee;
@@ -272,14 +281,14 @@ public class SendController extends WalletFormController implements Initializabl
}
FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection();
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.BLOCK_TARGET : feeRatesSelection);
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.MEMPOOL_SIZE : feeRatesSelection);
setDefaultFeeRate();
updateFeeRateSelection(feeRatesSelection);
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle);
feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
Config.get().setFeeRatesSelection(newFeeRatesSelection);
EventManager.get().post(new FeeRatesSelectionChangedEvent(newFeeRatesSelection));
EventManager.get().post(new FeeRatesSelectionChangedEvent(getWalletForm().getWallet(), newFeeRatesSelection));
});
fee.setTextFormatter(new CoinTextFormatter());
@@ -327,6 +336,10 @@ public class SendController extends WalletFormController implements Initializabl
if(userFeeSet.get()) {
setTargetBlocks(getTargetBlocks(feeRate));
setFeeRangeRate(feeRate);
if(walletTransaction.getFee() != getFeeValueSats()) {
setFeeValueSats(walletTransaction.getFee());
}
} else {
setFeeValueSats(walletTransaction.getFee());
}
@@ -337,6 +350,17 @@ public class SendController extends WalletFormController implements Initializabl
transactionDiagram.update(walletTransaction);
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
});
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
if(oldScene == null && newScene != null) {
transactionDiagram.update(null);
newScene.getWindow().heightProperty().addListener((observable1, oldValue, newValue) -> {
transactionDiagram.update(walletTransactionProperty.get());
});
}
});
addFeeRangeTrackHighlight(0);
}
private void initializeTabHeader(int count) {
@@ -436,7 +460,8 @@ public class SendController extends WalletFormController implements Initializabl
try {
if(paymentTabs.getTabs().size() == 1) {
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
updateTransaction(List.of(controller.getPayment(sendAll)));
controller.setSendMax(sendAll);
updateTransaction(List.of(controller.getPayment()));
} else {
updateTransaction(null);
}
@@ -544,10 +569,12 @@ public class SendController extends WalletFormController implements Initializabl
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
int maxTargetBlocks = 1;
for(Integer targetBlocks : targetBlocksFeeRates.keySet()) {
maxTargetBlocks = Math.max(maxTargetBlocks, targetBlocks);
Double candidate = targetBlocksFeeRates.get(targetBlocks);
if(Math.round(feeRate) >= Math.round(candidate)) {
return targetBlocks;
if(TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
maxTargetBlocks = Math.max(maxTargetBlocks, targetBlocks);
Double candidate = targetBlocksFeeRates.get(targetBlocks);
if(Math.round(feeRate) >= Math.round(candidate)) {
return targetBlocks;
}
}
}
@@ -609,6 +636,59 @@ public class SendController extends WalletFormController implements Initializabl
private void setFeeRate(Double feeRateAmt) {
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vByte");
setFeeRatePriority(feeRateAmt);
}
private void setFeeRatePriority(Double feeRateAmt) {
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
Integer targetBlocks = getTargetBlocks(feeRateAmt);
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
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");
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
return;
}
Double lowestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(TARGET_BLOCKS_RANGE.size() - 1));
if(lowestBlocksRate > minFeeRate && feeRateAmt < (minFeeRate + ((lowestBlocksRate - minFeeRate) / 2))) {
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");
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.PLUS_CIRCLE);
return;
}
}
if(targetBlocks != null) {
if(targetBlocks < FeeRatesSource.BLOCKS_IN_HALF_HOUR) {
Double maxFeeRate = FEE_RATES_RANGE.get(FEE_RATES_RANGE.size() - 1).doubleValue();
Double highestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(0));
if(highestBlocksRate < maxFeeRate && feeRateAmt > (highestBlocksRate + ((maxFeeRate - highestBlocksRate) / 10))) {
feeRatePriority.setText("Overpaid");
feeRatePriority.setTooltip(new Tooltip("Transaction fees at this rate are likely higher than necessary"));
feeRatePriorityGlyph.setStyle("-fx-text-fill: #c8416499");
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
} else {
feeRatePriority.setText("High Priority");
feeRatePriority.setTooltip(new Tooltip("Typically confirms within minutes"));
feeRatePriorityGlyph.setStyle("-fx-text-fill: #c8416499");
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
}
} else if(targetBlocks < FeeRatesSource.BLOCKS_IN_HOUR) {
feeRatePriority.setText("Medium Priority");
feeRatePriority.setTooltip(new Tooltip("Typically confirms within an hour or two"));
feeRatePriorityGlyph.setStyle("-fx-text-fill: #fba71b99");
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
} else {
feeRatePriority.setText("Low Priority");
feeRatePriority.setTooltip(new Tooltip("Typically confirms in a day or longer"));
feeRatePriorityGlyph.setStyle("-fx-text-fill: #41a9c999");
feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
}
}
}
private Node getSliderThumb() {
@@ -621,6 +701,47 @@ public class SendController extends WalletFormController implements Initializabl
}
}
private void addFeeRangeTrackHighlight(int count) {
Platform.runLater(() -> {
Node track = feeRange.lookup(".track");
if(track != null) {
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
String highlight = "";
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
}
highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";
track.setStyle("-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, " + highlight + ")");
} else if(count < 20) {
addFeeRangeTrackHighlight(count+1);
}
});
}
private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
Collections.reverse(rates);
for(Integer targetBlocks : rates) {
if(targetBlocks < minTargetBlocks) {
return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
}
}
return 100;
}
private int getPercentageOfFeeRange(Double feeRate) {
double index = Math.log(feeRate) / Math.log(2);
return (int)Math.round(index * 10.0);
}
private void updateMaxClearButtons(UtxoSelector utxoSelector, UtxoFilter utxoFilter) {
if(utxoSelector instanceof PresetUtxoSelector) {
PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
@@ -717,12 +838,16 @@ public class SendController extends WalletFormController implements Initializabl
public void createTransaction(ActionEvent event) {
if(log.isDebugEnabled()) {
Map<WalletNode, String> nodeHashes = walletTransactionProperty.get().getSelectedUtxos().values().stream().collect(Collectors.toMap(Function.identity(), node -> ElectrumServer.getScriptHash(walletForm.getWallet(), node)));
Map<WalletNode, String> changeHash = Collections.emptyMap();
if(walletTransactionProperty.get().getChangeNode() != null) {
changeHash = Map.of(walletTransactionProperty.get().getChangeNode(), ElectrumServer.getScriptHash(walletForm.getWallet(), walletTransactionProperty.get().getChangeNode()));
Map<WalletNode, List<String>> inputHashes = new LinkedHashMap<>();
for(WalletNode node : walletTransactionProperty.get().getSelectedUtxos().values()) {
List<String> nodeHashes = inputHashes.computeIfAbsent(node, k -> new ArrayList<>());
nodeHashes.add(ElectrumServer.getScriptHash(walletForm.getWallet(), node));
}
log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + nodeHashes + " and \nchange \n" + changeHash);
Map<WalletNode, List<String>> changeHash = Collections.emptyMap();
if(walletTransactionProperty.get().getChangeNode() != null) {
changeHash = Map.of(walletTransactionProperty.get().getChangeNode(), List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), walletTransactionProperty.get().getChangeNode())));
}
log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
}
addWalletTransactionNodes();
@@ -797,11 +922,14 @@ public class SendController extends WalletFormController implements Initializabl
if(targetBlocksField.isVisible()) {
setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
}
addFeeRangeTrackHighlight(0);
}
@Subscribe
public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
updateFeeRateSelection(event.getFeeRateSelection());
if(event.getWallet() == getWalletForm().getWallet()) {
updateFeeRateSelection(event.getFeeRateSelection());
}
}
@Subscribe
@@ -24,6 +24,7 @@ import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.util.StringConverter;
import org.controlsfx.control.RangeSlider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -105,6 +106,18 @@ public class SettingsController extends WalletFormController implements Initiali
}
});
scriptType.setConverter(new StringConverter<>() {
@Override
public String toString(ScriptType scriptType) {
return scriptType == null ? "" : scriptType.getDescription();
}
@Override
public ScriptType fromString(String string) {
return Arrays.stream(ScriptType.values()).filter(type -> type.getDescription().equals(string)).findFirst().orElse(null);
}
});
scriptType.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, scriptType) -> {
if(scriptType != null) {
walletForm.getWallet().setScriptType(scriptType);
@@ -170,7 +183,7 @@ public class SettingsController extends WalletFormController implements Initiali
apply.setOnAction(event -> {
revert.setDisable(true);
apply.setDisable(true);
saveWallet(false);
saveWallet(false, false);
});
setFieldsFromWallet(walletForm.getWallet());
@@ -290,8 +303,13 @@ public class SettingsController extends WalletFormController implements Initiali
}
public void showAdvanced(ActionEvent event) {
AdvancedDialog advancedDialog = new AdvancedDialog(walletForm.getWallet());
advancedDialog.showAndWait();
AdvancedDialog advancedDialog = new AdvancedDialog(walletForm);
Optional<Boolean> optApply = advancedDialog.showAndWait();
if(optApply.isPresent() && optApply.get() && walletForm.getWallet().isValid()) {
revert.setDisable(true);
apply.setDisable(true);
saveWallet(false, true);
}
}
@Override
@@ -317,7 +335,10 @@ public class SettingsController extends WalletFormController implements Initiali
wallet.setDefaultPolicy(Policy.getPolicy(wallet.getPolicyType(), wallet.getScriptType(), wallet.getKeystores(), (int)multisigControl.getLowValue()));
}
descriptor.setWallet(wallet);
if(ScriptType.getAddressableScriptTypes(wallet.getPolicyType()).contains(wallet.getScriptType())) {
descriptor.setWallet(wallet);
}
revert.setDisable(false);
apply.setDisable(!wallet.isValid());
}
@@ -339,7 +360,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
private void saveWallet(boolean changePassword) {
private void saveWallet(boolean changePassword, boolean suggestChangePassword) {
ECKey existingPubKey = walletForm.getStorage().getEncryptionPubKey();
WalletPasswordDialog.PasswordRequirement requirement;
@@ -364,7 +385,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
WalletPasswordDialog dlg = new WalletPasswordDialog(requirement);
WalletPasswordDialog dlg = new WalletPasswordDialog(null, requirement, suggestChangePassword);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
if(dlg.isBackupExisting()) {
@@ -383,7 +404,7 @@ public class SettingsController extends WalletFormController implements Initiali
try {
walletForm.getStorage().setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
walletForm.saveAndRefresh();
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW) {
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW || requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_CHANGE) {
EventManager.get().post(new RequestOpenWalletsEvent());
}
} catch (IOException e) {
@@ -409,18 +430,28 @@ public class SettingsController extends WalletFormController implements Initiali
return;
}
key = new Key(encryptionFullKey.getPrivKeyBytes(), walletForm.getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
if(dlg.isChangePassword()) {
if(dlg.isDeleteBackups()) {
walletForm.deleteBackups();
}
walletForm.getStorage().setEncryptionPubKey(null);
saveWallet(true);
walletForm.getWallet().decrypt(key);
saveWallet(true, false);
return;
}
key = new Key(encryptionFullKey.getPrivKeyBytes(), walletForm.getStorage().getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
walletForm.getWallet().encrypt(key);
if(dlg.isDeleteBackups()) {
walletForm.deleteBackups();
}
walletForm.getWallet().encrypt(key);
walletForm.getStorage().setEncryptionPubKey(encryptionPubKey);
walletForm.saveAndRefresh();
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW) {
if(requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_NEW || requirement == WalletPasswordDialog.PasswordRequirement.UPDATE_EMPTY) {
EventManager.get().post(new RequestOpenWalletsEvent());
}
} catch (Exception e) {
@@ -17,7 +17,7 @@ public class SettingsWalletForm extends WalletForm {
private Wallet walletCopy;
public SettingsWalletForm(Storage storage, Wallet currentWallet) {
super(storage, currentWallet);
super(storage, currentWallet, null, false);
this.walletCopy = currentWallet.copy();
}
@@ -38,8 +38,13 @@ public class SettingsWalletForm extends WalletForm {
@Override
public void saveAndRefresh() throws IOException {
Wallet pastWallet = null;
boolean refreshAll = isRefreshNecessary(wallet, walletCopy);
if(refreshAll) {
pastWallet = wallet.copy();
save(); //Save here for the temp backup in case password has been changed
getStorage().backupTempWallet();
walletCopy.clearNodes();
}
@@ -47,7 +52,7 @@ public class SettingsWalletForm extends WalletForm {
save();
if(refreshAll) {
EventManager.get().post(new WalletSettingsChangedEvent(wallet, getWalletFile()));
EventManager.get().post(new WalletSettingsChangedEvent(wallet, pastWallet, getWalletFile()));
}
}
@@ -1,6 +1,9 @@
package com.sparrowwallet.sparrow.wallet;
import com.csvreader.CsvWriter;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
@@ -8,14 +11,26 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TreeItem;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.ResourceBundle;
public class TransactionsController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(TransactionsController.class);
@FXML
private CoinLabel balance;
@@ -38,6 +53,9 @@ public class TransactionsController extends WalletFormController implements Init
@FXML
private BalanceChart balanceChart;
@FXML
private Button exportCsv;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -81,6 +99,41 @@ public class TransactionsController extends WalletFormController implements Init
transactionCount.setText(walletTransactionsEntry.getChildren() != null ? Integer.toString(walletTransactionsEntry.getChildren().size()) : "0");
}
public void exportCSV(ActionEvent event) {
WalletTransactionsEntry walletTransactionsEntry = getWalletForm().getWalletTransactionsEntry();
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export Transactions as CSV");
fileChooser.setInitialFileName(getWalletForm().getWallet().getName() + ".csv");
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[] {"Date", "Label", "Value", "Balance"});
for(Entry entry : walletTransactionsEntry.getChildren()) {
TransactionEntry txEntry = (TransactionEntry)entry;
writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(txEntry.getBlockTransaction().getDate()));
writer.write(txEntry.getLabel());
writer.write(getCoinValue(txEntry.getValue()));
writer.write(getCoinValue(txEntry.getBalance()));
writer.endRecord();
}
writer.close();
} catch(IOException e) {
log.error("Error exporting transactions as CSV", e);
AppServices.showErrorDialog("Error exporting transactions as CSV", e.getMessage());
}
}
}
private String getCoinValue(Long value) {
return BitcoinUnit.BTC.equals(transactionsTable.getBitcoinUnit()) ?
CoinLabel.getBTCFormat().format(value.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN) :
String.format(Locale.ENGLISH, "%d", value);
}
@Subscribe
public void walletNodesChanged(WalletNodesChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
@@ -162,6 +215,11 @@ public class TransactionsController extends WalletFormController implements Init
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), false));
}
@Subscribe
private void connectionFailed(ConnectionFailedEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), false));
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
@@ -149,6 +149,11 @@ public class UtxosController extends WalletFormController implements Initializab
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), false));
}
@Subscribe
private void connectionFailed(ConnectionFailedEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), false));
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
@@ -2,14 +2,18 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
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.WalletTabData;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,16 +27,27 @@ public class WalletForm {
private final Storage storage;
protected Wallet wallet;
private Wallet savedPastWallet;
private WalletTransactionsEntry walletTransactionsEntry;
private WalletUtxosEntry walletUtxosEntry;
private final List<NodeEntry> accountEntries = new ArrayList<>();
private final List<Set<WalletNode>> walletTransactionNodes = new ArrayList<>();
public WalletForm(Storage storage, Wallet currentWallet) {
public WalletForm(Storage storage, Wallet currentWallet, Wallet backupWallet) {
this(storage, currentWallet, backupWallet, true);
}
public WalletForm(Storage storage, Wallet currentWallet, Wallet backupWallet, boolean refreshHistory) {
this.storage = storage;
this.wallet = currentWallet;
refreshHistory(AppServices.getCurrentBlockHeight());
//Unencrypted wallets load before isConnected is true, waiting for the ConnectionEvent to refresh history - save the backup for this event
savedPastWallet = backupWallet;
if(refreshHistory) {
refreshHistory(AppServices.getCurrentBlockHeight(), backupWallet);
}
}
public Wallet getWallet() {
@@ -60,51 +75,83 @@ public class WalletForm {
}
public void saveAndRefresh() throws IOException {
Wallet pastWallet = wallet.copy();
storage.backupTempWallet();
wallet.clearHistory();
save();
refreshHistory(AppServices.getCurrentBlockHeight());
refreshHistory(AppServices.getCurrentBlockHeight(), pastWallet);
}
public void saveBackup() throws IOException {
storage.backupWallet();
}
public void refreshHistory(Integer blockHeight) {
refreshHistory(blockHeight, null);
public void deleteBackups() {
storage.deleteBackups();
}
public void refreshHistory(Integer blockHeight, WalletNode node) {
public void refreshHistory(Integer blockHeight, Wallet pastWallet) {
refreshHistory(blockHeight, pastWallet, null);
}
public void refreshHistory(Integer blockHeight, Wallet pastWallet, WalletNode node) {
Wallet previousWallet = wallet.copy();
if(wallet.isValid() && AppServices.isConnected()) {
log.debug(node == null ? wallet.getName() + " refreshing full wallet history" : wallet.getName() + " requesting node wallet history for " + node.getDerivationPath());
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, getWalletTransactionNodes(node));
historyService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false));
updateWallet(previousWallet, blockHeight);
EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
updateWallet(blockHeight, pastWallet, previousWallet);
});
historyService.setOnFailed(workerStateEvent -> {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
EventManager.get().post(new WalletHistoryStatusEvent(wallet, workerStateEvent.getSource().getException().getMessage()));
EventManager.get().post(new WalletHistoryFinishedEvent(wallet, workerStateEvent.getSource().getException().getMessage()));
});
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true));
if(node == null && wallet.getTransactions().isEmpty()) {
EventManager.get().post(new WalletHistoryStartedEvent(wallet));
} else {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true));
}
historyService.start();
}
}
private void updateWallet(Wallet previousWallet, Integer blockHeight) {
private void updateWallet(Integer blockHeight, Wallet pastWallet, Wallet previousWallet) {
if(blockHeight != null) {
wallet.setStoredBlockHeight(blockHeight);
}
notifyIfChanged(previousWallet, blockHeight);
boolean labelsChanged = false;
if(pastWallet != null) {
labelsChanged = copyLabels(pastWallet);
}
notifyIfChanged(blockHeight, previousWallet, labelsChanged);
}
private void notifyIfChanged(Wallet previousWallet, Integer blockHeight) {
private boolean copyLabels(Wallet pastWallet) {
boolean changed = wallet.getNode(KeyPurpose.RECEIVE).copyLabels(pastWallet.getNode(KeyPurpose.RECEIVE));
changed |= wallet.getNode(KeyPurpose.CHANGE).copyLabels(pastWallet.getNode(KeyPurpose.CHANGE));
for(Map.Entry<Sha256Hash, BlockTransaction> txEntry : wallet.getTransactions().entrySet()) {
BlockTransaction pastBlockTransaction = pastWallet.getTransactions().get(txEntry.getKey());
if(pastBlockTransaction != null && txEntry.getValue() != null && txEntry.getValue().getLabel() == null && pastBlockTransaction.getLabel() != null) {
txEntry.getValue().setLabel(pastBlockTransaction.getLabel());
changed = true;
}
}
storage.deleteBackups(Storage.TEMP_BACKUP_EXTENSION);
return changed;
}
private void notifyIfChanged(Integer blockHeight, Wallet previousWallet, boolean labelsChanged) {
List<WalletNode> historyChangedNodes = new ArrayList<>();
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), wallet.getNode(KeyPurpose.RECEIVE).getChildren()));
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.CHANGE).getChildren(), wallet.getNode(KeyPurpose.CHANGE).getChildren()));
boolean changed = false;
boolean changed = labelsChanged;
if(!historyChangedNodes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, storage, historyChangedNodes)));
changed = true;
@@ -226,7 +273,13 @@ public class WalletForm {
walletUtxosEntry = null;
accountEntries.clear();
EventManager.get().post(new WalletNodesChangedEvent(wallet));
refreshHistory(AppServices.getCurrentBlockHeight());
//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());
}
}
@@ -234,13 +287,14 @@ public class WalletForm {
public void newBlock(NewBlockEvent event) {
//Check if wallet is valid to avoid saving wallets in initial setup
if(wallet.isValid()) {
updateWallet(wallet.copy(), event.getHeight());
updateWallet(event.getHeight(), null, wallet.copy());
}
}
@Subscribe
public void connected(ConnectionEvent event) {
refreshHistory(event.getBlockHeight());
refreshHistory(event.getBlockHeight(), savedPastWallet);
savedPastWallet = null;
}
@Subscribe
@@ -249,7 +303,7 @@ public class WalletForm {
WalletNode walletNode = event.getWalletNode(wallet);
if(walletNode != null) {
log.debug(wallet.getName() + " history event for node " + walletNode + " (" + event.getScriptHash() + ")");
refreshHistory(AppServices.getCurrentBlockHeight(), walletNode);
refreshHistory(AppServices.getCurrentBlockHeight(), null, walletNode);
}
}
}
+2
View File
@@ -26,4 +26,6 @@ open module com.sparrowwallet.sparrow {
requires jcommander;
requires slf4j.api;
requires bwt.jni;
requires jtorctl;
requires javacsv;
}
@@ -8,7 +8,7 @@
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<StackPane prefHeight="420.0" prefWidth="600.0" stylesheets="@about.css, @general.css" fx:controller="com.sparrowwallet.sparrow.AboutController" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<StackPane prefHeight="460.0" prefWidth="600.0" stylesheets="@about.css, @general.css" fx:controller="com.sparrowwallet.sparrow.AboutController" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<VBox spacing="20">
<HBox styleClass="title-area">
<HBox alignment="CENTER_LEFT">
@@ -21,8 +21,8 @@
</HBox>
<VBox spacing="10" styleClass="content-area">
<Label text="Sparrow is a Bitcoin wallet with the goal of providing greater transparency and usability on the path to full financial self sovereignty. It attempts to provide all of the detail about your wallet setup, transactions and UTXOs so that you can transact will a full understanding of your money." wrapText="true" />
<Label text="Sparrow can operate in both an online and offline mode. In the online mode it connects to your Bitcoin Core node or Electrum server to display transaction history. In the offline mode it is useful as a transaction editor and as an airgapped multisig coordinator." wrapText="true" />
<Label text="Connecting Sparrow to your Bitcoin Core node ensures your privacy, while connecting Sparrow to your own Electrum server ensures wallets load quicker, you have access to a full blockchain explorer, and your public keys are always encrypted on disk. " wrapText="true" />
<Label text="Sparrow can operate in both an online and offline mode. In the online mode it connects to a Bitcoin Core node or Electrum server to display transaction history. In the offline mode it is useful as a transaction editor and as an airgapped multisig coordinator." wrapText="true" />
<Label text="While it's possible to connect to a public Electrum server, connecting Sparrow to your Bitcoin Core node ensures your privacy, while connecting Sparrow to your private Electrum server ensures wallets load quicker, you have access to a full blockchain explorer, and your public keys are always encrypted on disk. " wrapText="true" />
<HBox><Label text="If you find Sparrow useful, consider donating at "/><Hyperlink text="https://sparrowwallet.com/donate" onAction="#openDonate"/></HBox>
</VBox>
<HBox styleClass="button-area" alignment="BOTTOM_RIGHT" VBox.vgrow="SOMETIMES">
@@ -34,3 +34,8 @@
-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;
}
.public-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(gold, 30%), gold);
-fx-background-insets: 0, 1;
}
@@ -9,7 +9,7 @@
<?import com.sparrowwallet.drongo.BitcoinUnit?>
<?import com.sparrowwallet.sparrow.Theme?>
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1000.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1020.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<children>
<MenuBar useSystemMenuBar="true">
<menus>
@@ -22,7 +22,7 @@
<MenuItem text="File..." onAction="#openTransactionFromFile" accelerator="Shortcut+F"/>
<MenuItem fx:id="openTransactionIdItem" text="From ID..." onAction="#openTransactionFromId" accelerator="Shortcut+I"/>
<MenuItem text="From Text..." onAction="#openTransactionFromText" accelerator="Shortcut+T"/>
<MenuItem text="From QR..." onAction="#openTransactionFromQR" accelerator="Shortcut+R"/>
<MenuItem text="From QR..." onAction="#openTransactionFromQR" accelerator="Shortcut+U"/>
<!-- <MenuItem text="Examples" onAction="#openExamples"/> -->
</items>
</Menu>
@@ -82,12 +82,17 @@
<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="showTxHex" mnemonicParsing="false" text="Show Transaction Hex" onAction="#showTxHex"/>
<SeparatorMenuItem />
<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"/>
</Menu>
<Menu fx:id="helpMenu" mnemonicParsing="false" text="Help">
<MenuItem mnemonicParsing="false" text="Show Introduction" onAction="#showIntroduction"/>
<MenuItem mnemonicParsing="false" text="Show Online Documentation" onAction="#showDocumentation"/>
<MenuItem mnemonicParsing="false" text="Show Log File" onAction="#showLogFile"/>
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="About Sparrow" onAction="#showAbout"/>
</Menu>
</menus>
@@ -163,4 +163,8 @@
.box-overlay {
-fx-background-color: -fx-box-border, derive(-fx-background, 10%);
-fx-effect: dropshadow(three-pass-box, #a0a1a7, 14, 0, 0, 0);
}
.alert .content.label {
-fx-padding: 20px 20px 20px 20px;
}
@@ -71,6 +71,12 @@
</ComboBox>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Wallet" styleClass="wideLabelFieldSet">
<Field text="Validate derivations:">
<UnlabeledToggleSwitch fx:id="validateDerivationPaths" />
<HelpLabel helpText="Disallow keystores to have derivation paths that match the defaults for other script types."/>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Coin Selection" styleClass="wideLabelFieldSet">
<Field text="Group by address:">
<UnlabeledToggleSwitch fx:id="groupByAddress" />
@@ -31,4 +31,21 @@
.scroll-pane {
-fx-background-color: transparent;
}
}
.public-warning {
-fx-text-fill: rgb(238, 210, 2);
}
.public-electrum {
-fx-text-fill: linear-gradient(to bottom, derive(rgb(238, 210, 2), 30%), rgb(238, 210, 2));
}
.bitcoin-core {
-fx-text-fill: linear-gradient(to bottom, derive(#50a14f, 30%), #50a14f);
}
.private-electrum {
-fx-text-fill: linear-gradient(to bottom, derive(#0b99c9, 30%), #0b99c9);
}
@@ -16,6 +16,9 @@
<?import com.sparrowwallet.sparrow.net.ServerType?>
<?import com.sparrowwallet.sparrow.net.CoreAuthType?>
<?import com.sparrowwallet.sparrow.control.HelpLabel?>
<?import javafx.collections.FXCollections?>
<?import com.sparrowwallet.sparrow.net.PublicElectrumServer?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
<GridPane hgap="10.0" vgap="10.0" stylesheets="@preferences.css, @../general.css" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.preferences.ServerPreferencesController">
<padding>
<Insets left="25.0" right="25.0" top="25.0" />
@@ -30,17 +33,31 @@
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Server">
<Field text="Type:">
<SegmentedButton>
<SegmentedButton fx:id="serverTypeSegmentedButton">
<toggleGroup>
<ToggleGroup fx:id="serverTypeToggleGroup" />
</toggleGroup>
<buttons>
<ToggleButton fx:id="publicElectrumToggle" text="Public Server" toggleGroup="$serverTypeToggleGroup">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="TOGGLE_ON" styleClass="public-electrum" />
</graphic>
<userData>
<ServerType fx:constant="PUBLIC_ELECTRUM_SERVER"/>
</userData>
</ToggleButton>
<ToggleButton text="Bitcoin Core" toggleGroup="$serverTypeToggleGroup">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="TOGGLE_ON" styleClass="bitcoin-core" />
</graphic>
<userData>
<ServerType fx:constant="BITCOIN_CORE"/>
</userData>
</ToggleButton>
<ToggleButton text="Electrum Server" toggleGroup="$serverTypeToggleGroup">
<ToggleButton text="Private Electrum" toggleGroup="$serverTypeToggleGroup">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="TOGGLE_ON" styleClass="private-electrum" />
</graphic>
<userData>
<ServerType fx:constant="ELECTRUM_SERVER"/>
</userData>
@@ -51,11 +68,50 @@
</Fieldset>
</Form>
<Form fx:id="publicElectrumForm" GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="Public Server">
<Field text="">
<Label alignment="CENTER" translateY="-1">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="EXCLAMATION_TRIANGLE" styleClass="public-warning" />
</graphic>
<padding>
<Insets right="5" />
</padding>
</Label>
<CopyableLabel text="Warning!" />
</Field>
<Field text="">
<CopyableLabel text="Using a public server means it can see your transactions."/>
</Field>
<Field text="URL:">
<ComboBox fx:id="publicElectrumServer">
<items>
<FXCollections fx:factory="observableArrayList">
<PublicElectrumServer fx:constant="BLOCKSTREAM_INFO" />
<PublicElectrumServer fx:constant="ELECTRUM_BLOCKSTREAM_INFO" />
<PublicElectrumServer fx:constant="LUKECHILDS_CO" />
<PublicElectrumServer fx:constant="EMZY_DE" />
<PublicElectrumServer fx:constant="BITAROO_NET" />
</FXCollections>
</items>
</ComboBox>
</Field>
<Field text="Use Proxy:">
<UnlabeledToggleSwitch fx:id="publicUseProxy"/>
</Field>
<Field text="Proxy URL:">
<TextField fx:id="publicProxyHost" />
<TextField fx:id="publicProxyPort" maxWidth="100" />
</Field>
</Fieldset>
</Form>
<Form fx:id="coreForm" GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="Bitcoin Core RPC">
<Field text="URL:">
<TextField fx:id="coreHost" promptText="e.g. 127.0.0.1"/>
<TextField fx:id="corePort" promptText="e.g. 8332" prefWidth="80" />
<TextField fx:id="corePort" promptText="e.g. 8332" maxWidth="100" />
</Field>
<Field text="Authentication:">
<SegmentedButton>
@@ -89,7 +145,7 @@
<PasswordField fx:id="corePass"/>
</Field>
<Field text="Multi-Wallet:">
<UnlabeledToggleSwitch fx:id="coreMultiWallet"/> <HelpLabel helpText="Enable this if using multiple Bitcoin Core wallets" />
<UnlabeledToggleSwitch fx:id="coreMultiWallet"/> <HelpLabel helpText="Creates a new Bitcoin Core wallet with the following name (recommended to avoid conflicts)" />
</Field>
<Field text="Wallet Name:" styleClass="label-button">
<TextField fx:id="coreWallet"/>
@@ -98,10 +154,10 @@
</Form>
<Form fx:id="electrumForm" GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="Electrum Server">
<Fieldset inputGrow="SOMETIMES" text="Private Electrum Server">
<Field text="URL:">
<TextField fx:id="electrumHost" promptText="e.g. 127.0.0.1"/>
<TextField fx:id="electrumPort" promptText="e.g. 50002" prefWidth="80" />
<TextField fx:id="electrumHost" promptText="e.g. 127.0.0.1 or Tor hostname (.onion)"/>
<TextField fx:id="electrumPort" promptText="e.g. 50002" maxWidth="100" />
</Field>
<Field text="Use SSL:">
<UnlabeledToggleSwitch fx:id="electrumUseSsl"/>
@@ -119,7 +175,7 @@
</Field>
<Field text="Proxy URL:">
<TextField fx:id="proxyHost" />
<TextField fx:id="proxyPort" prefWidth="80" />
<TextField fx:id="proxyPort" maxWidth="100" />
</Field>
</Fieldset>
</Form>
@@ -49,7 +49,7 @@
<Label style="-fx-pref-width: 15" />
<FiatLabel fx:id="fiatAmount" />
<Region style="-fx-pref-width: 20" />
<Button fx:id="maxButton" text="Max" onAction="#setMaxInput" />
<ToggleButton fx:id="maxButton" text="Max" onAction="#setMaxInput" />
</Field>
</Fieldset>
</Form>
@@ -65,10 +65,6 @@
-fx-alignment: center-left;
}
#transactionDiagram {
-fx-min-height: 230px;
}
#transactionDiagram .boundary {
-fx-stroke: transparent;
}
@@ -91,6 +91,12 @@
</Field>
<Field fx:id="feeRateField" text="Rate:">
<CopyableLabel fx:id="feeRate" />
<Region HBox.hgrow="ALWAYS" />
<Label fx:id="feeRatePriority" graphicTextGap="5" contentDisplay="RIGHT">
<graphic>
<Glyph fx:id="feeRatePriorityGlyph" fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CIRCLE" />
</graphic>
</Label>
</Field>
<Field text="Fee:">
<TextField fx:id="fee" styleClass="amount-field"/>
@@ -127,7 +133,7 @@
</AnchorPane>
</GridPane>
<AnchorPane>
<TransactionDiagram fx:id="transactionDiagram" maxWidth="700" maxHeight="230" AnchorPane.leftAnchor="100" />
<TransactionDiagram fx:id="transactionDiagram" maxWidth="700" AnchorPane.leftAnchor="100" />
</AnchorPane>
</VBox>
</center>
@@ -13,6 +13,7 @@
<?import org.fxmisc.flowless.VirtualizedScrollPane?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import com.sparrowwallet.sparrow.control.HelpLabel?>
<BorderPane stylesheets="@settings.css, @wallet.css, @../script.css, @../descriptor.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.SettingsController">
<center>
<GridPane hgap="10.0" vgap="10.0">
@@ -40,6 +41,7 @@
</FXCollections>
</items>
</ComboBox>
<HelpLabel helpText="Single signature wallets use a single keystore (hardware wallet or seed).\nMultisignature wallets require multiple keystores (N), of which a certain number (M) need to sign." />
</Field>
<Field text="Script Type:">
<ComboBox fx:id="scriptType">
@@ -55,6 +57,7 @@
</FXCollections>
</items>
</ComboBox>
<HelpLabel helpText="Native Segwit types are the default and are usually the best choice.\nNested Segwit types are a good choice for the widest compatibility with older wallets.\nLegacy types should be avoided, unless configuring an old wallet." />
</Field>
</Fieldset>
</Form>
@@ -22,4 +22,16 @@
.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;
}
@@ -15,6 +15,7 @@
<?import com.sparrowwallet.sparrow.control.CoinLabel?>
<?import com.sparrowwallet.sparrow.control.FiatLabel?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
<?import org.controlsfx.glyphfont.Glyph?>
<BorderPane stylesheets="@transactions.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.TransactionsController">
<center>
@@ -40,6 +41,14 @@
</Field>
<Field text="Transactions:">
<CopyableLabel fx:id="transactionCount" />
<Button fx:id="exportCsv" maxHeight="25" onAction="#exportCSV" translateY="-1">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="ARROW_CIRCLE_DOWN" fontSize="12" />
</graphic>
<tooltip>
<Tooltip text="Export transactions as CSV" />
</tooltip>
</Button>
</Field>
</Fieldset>
</Form>
@@ -0,0 +1,64 @@
.welcome-pane {
-fx-padding: 0;
}
.title-area {
-fx-background-color: -fx-control-inner-background;
-fx-padding: 10 25 10 25;
-fx-border-width: 0px 0px 1px 0px;
-fx-border-color: #e5e5e6;
}
#welcomeBox, .button-bar {
-fx-padding: 10 25 25 25;
}
.button-bar .container {
-fx-padding: 0 0 15px 0;
}
.title-label {
-fx-font-size: 24px;
}
.title-text {
-fx-font-size: 20px;
-fx-padding: 0 0 15px 0;
-fx-graphic-text-gap: 10px;
}
.content-text {
-fx-font-size: 16px;
-fx-text-fill: derive(-fx-text-base-color, 15%);
}
.offline .title-icon {
-fx-text-fill: linear-gradient(to bottom, derive(-fx-text-base-color, 30%), -fx-text-base-color);
}
.public-electrum .title-icon {
-fx-text-fill: linear-gradient(to bottom, derive(rgb(238, 210, 2), 30%), rgb(238, 210, 2));
}
.bitcoin-core .title-icon {
-fx-text-fill: linear-gradient(to bottom, derive(#50a14f, 30%), #50a14f);
}
.private-electrum .title-icon {
-fx-text-fill: linear-gradient(to bottom, derive(#0b99c9, 30%), #0b99c9);
}
.public-electrum .toggle-switch:selected .thumb-area, .offline .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(gold, 30%), gold);
-fx-background-insets: 0, 1;
}
.bitcoin-core .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;
}
.private-electrum .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(#0b99c9, 30%), #0b99c9);
-fx-background-insets: 0, 1;
}
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import org.controlsfx.control.StatusBar?>
<?import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<StackPane prefHeight="460.0" prefWidth="600.0" stylesheets="@welcome.css, @general.css" styleClass="welcome-pane" fx:controller="com.sparrowwallet.sparrow.WelcomeController" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<VBox spacing="20">
<HBox styleClass="title-area">
<HBox alignment="CENTER_LEFT">
<Label fx:id="title" text="Welcome to Sparrow" styleClass="title-label" />
</HBox>
<Region HBox.hgrow="ALWAYS"/>
<ImageView AnchorPane.rightAnchor="0">
<Image url="/image/sparrow-small.png" requestedWidth="50" requestedHeight="50" smooth="false" />
</ImageView>
</HBox>
<VBox fx:id="welcomeBox" styleClass="content-area" spacing="20" prefHeight="370">
<VBox fx:id="step1" spacing="15">
<Label text="Introduction" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="TOGGLE_OFF" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Sparrow is a Bitcoin wallet with a focus on security and usability." wrapText="true" styleClass="content-text" />
<Label text="Sparrow can operate in both an online and offline mode. In the online mode it connects to a server to display transaction history. In the offline mode it is useful as a transaction editor and as an airgapped multisig coordinator." wrapText="true" styleClass="content-text" />
<Label text="The status bar at the bottom displays the connection status, as demonstrated below:" wrapText="true" styleClass="content-text" />
</VBox>
<VBox fx:id="step2" spacing="15">
<Label text="Connecting to a Public Server" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="TOGGLE_ON" styleClass="title-icon" />
</graphic>
</Label>
<Label text="If you are beginning your journey in self custody, or just storing a small amount, the easiest way to connect Sparrow to the Bitcoin blockchain is via one of the preconfigured public Electrum servers. " wrapText="true" styleClass="content-text" />
<Label text="However, although Sparrow only connects to servers that have a record of respecting privacy, it is still not ideal as you are sharing your transaction history and balance with them." wrapText="true" styleClass="content-text" />
<Label text="A yellow toggle means you are connected to a public server." wrapText="true" styleClass="content-text" />
</VBox>
<VBox fx:id="step3" spacing="15">
<Label text="Connecting to a Bitcoin Core node" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="TOGGLE_ON" styleClass="title-icon" />
</graphic>
</Label>
<Label text="If you are running your own Bitcoin Core node, you can configure Sparrow to connect to it directly. " wrapText="true" styleClass="content-text" />
<Label text="This means you are not sharing your transaction data, but be aware Bitcoin Core stores your balance, transactions and public keys unencrypted on that node, which is not ideal for true cold storage." wrapText="true" styleClass="content-text" />
<Label text="A green toggle means you are connected to a Bitcoin Core node." wrapText="true" styleClass="content-text" />
</VBox>
<VBox fx:id="step4" spacing="15">
<Label text="Connecting to a Private Electrum Server" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="TOGGLE_ON" styleClass="title-icon" />
</graphic>
</Label>
<Label text="The most private way to connect is to your own Electrum server, which in turn connects to your Bitcoin Core node." wrapText="true" styleClass="content-text" />
<Label text="Because these servers index all Bitcoin transactions equally, your wallet transactions are never stored on the server in an identifiable way, and your server forgets your requests immediately after serving them." wrapText="true" styleClass="content-text" />
<Label text="A blue toggle means you are connected to a private Electrum server. You're now ready to configure a server and start using Sparrow!" wrapText="true" styleClass="content-text" />
</VBox>
<Region VBox.vgrow="ALWAYS" />
<StatusBar fx:id="serverStatus">
<rightItems>
<UnlabeledToggleSwitch fx:id="serverToggle" />
</rightItems>
</StatusBar>
</VBox>
</VBox>
</StackPane>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

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