Compare commits

...
60 Commits
Author SHA1 Message Date
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
Craig Raw 7fc37c21fd v0.9.10 2021-01-14 12:35:45 +02:00
Craig Raw ff828b483b improve status bar feedback when starting bwt, syncing and scanning 2021-01-13 20:25:55 +02:00
Craig Raw 64a9cce837 log level revert and minor startup fixes 2021-01-13 16:23:02 +02:00
Craig Raw caa7ef429c add backup confirmation overlay to bip39 list generation process 2021-01-13 15:43:39 +02:00
Craig Raw 38818d8222 always set charset to utf-8 when converting from bytes to string 2021-01-13 12:17:01 +02:00
Craig Raw fd7d2232a1 only start bwt with valid wallets 2021-01-13 11:40:29 +02:00
Craig Raw b5eb59344c avoid npe on new wallet 2021-01-13 11:33:20 +02:00
Craig Raw 3093e4906c fix npe on initial startup #2 2021-01-13 11:27:55 +02:00
Craig Raw 2faddc1133 fix npe on initial startup 2021-01-13 11:23:38 +02:00
Craig Raw a2ead56593 Merge branch 'bwt' 2021-01-13 09:12:01 +02:00
Craig Raw 03adc1f9f0 fix drongo head 2021-01-13 09:10:32 +02:00
Craig Raw 7bc707bcaa update linux and windows bwt 2021-01-13 08:46:57 +02:00
Craig Raw 7ae74e3000 handle mode and startup config better 2021-01-13 08:11:10 +02:00
Craig Raw 94da3b37b6 show tx count, set default server type 2021-01-12 14:43:30 +02:00
Craig Raw c6689228d0 handle new coldcard firmware p2sh_p2wsh import name change 2021-01-12 13:50:32 +02:00
Craig Raw d847da9d61 bwt preferences and connection handling improvements 2021-01-12 13:24:11 +02:00
Craig Raw 6637ea09bf fix isconnecting with ready status flag 2021-01-12 10:37:22 +02:00
Craig Raw 99959470e2 show warning before changing a wallet with transactions 2021-01-12 10:15:42 +02:00
Craig Raw 826162ba9f various bwt related tweaks 2021-01-11 18:26:45 +02:00
Craig Raw 546ccd66b3 upgrade to bwt with earlier termination, various bitcoin core related ui changes 2021-01-11 17:31:28 +02:00
Craig Raw 628b15a3b5 support broadcasting wallet and non-wallet loaded txes 2021-01-11 12:27:28 +02:00
Craig Raw 72281233e1 Merge branch 'master' into bwt 2021-01-08 17:01:32 +02:00
Craig Raw c68722e420 Merge branch 'master' of github.com:sparrowwallet/sparrow 2021-01-08 16:11:59 +02:00
Craig Raw 901a4520fd safe multithreading config writes 2021-01-08 16:11:36 +02:00
Craig Raw 3e083b907d bwt fixes and startup status 2021-01-08 16:10:00 +02:00
Craig Raw 3c4b25ecee bwt threading and wallet rescanning 2021-01-07 13:04:42 +02:00
craigrawandGitHub c23bbb9e02 Merge pull request #55 from bitcoinhodler/grammar
Fix grammar errors in README
2021-01-01 16:24:13 +02:00
Craig Raw 94897e9744 remove folder 2021-01-01 16:15:52 +02:00
Craig Raw d722c4972a initialise logger after configuring user defined application home 2021-01-01 10:40:08 +02:00
Bitcoin Hodler e93df8273b Fix grammar errors in README
"It's" means "it is".
2020-12-27 19:50:41 +00:00
Craig Raw a1c65cff75 initial commit of bwt integration 2020-12-20 12:34:36 +02:00
Craig Raw 18bc7bf302 improve debug logging for wallet history updates 2020-12-17 08:38:41 +02:00
Craig Raw 17bb442c78 ensure socket inputstream is buffered 2020-12-17 08:37:26 +02:00
Craig Raw 3134a9ad42 reduce time to check broadcasted tx 2020-12-16 12:37:38 +02:00
Craig Raw eda96af607 shorten details link to prevent scrollbars appearing 2020-12-16 12:10:01 +02:00
Craig Raw 038069f6e6 add scheduled service to check mempool after broadcast 2020-12-15 10:29:13 +02:00
98 changed files with 2665 additions and 420 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
+2 -2
View File
@@ -32,7 +32,7 @@ Java 14 must be installed.
## Configuration
Sparrow has a number of command line options, for example to change it's home folder or use testnet:
Sparrow has a number of command line options, for example to change its home folder or use testnet:
```
./sparrow -h
@@ -56,7 +56,7 @@ A final fallback which can be useful when running the Sparrow binary is to creat
Note that if you are connecting to an Electrum server when using testnet, that server will need to running on testnet configuration as well.
When not explicitly configured using the command line argument above, Sparrow stores it's mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
When not explicitly configured using the command line argument above, Sparrow stores its mainnet config file, log file and wallets in a home folder location appropriate to the operating system:
Platform | Location
-------- | --------
+6 -2
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.beryx.jlink' version '2.22.0'
}
def sparrowVersion = '0.9.9'
def sparrowVersion = '1.0.0'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -69,6 +69,7 @@ dependencies {
exclude group: 'org.openjfx', module: 'javafx-web'
exclude group: 'org.openjfx', module: 'javafx-media'
}
implementation('dev.bwt:bwt-jni:0.1.7')
testImplementation('junit:junit:4.12')
}
@@ -144,10 +145,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: 0567409742...a38206f17c
+2 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.9</string>
<string>1.0.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>
@@ -22,4 +22,8 @@ public class AboutController {
public void close(ActionEvent event) {
stage.close();
}
public void openDonate(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/donate");
}
}
@@ -23,6 +23,7 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.transaction.TransactionController;
import com.sparrowwallet.sparrow.transaction.TransactionData;
@@ -110,6 +111,8 @@ public class AppController implements Initializable {
@FXML
private UnlabeledToggleSwitch serverToggle;
private PauseTransition wait;
private Timeline statusTimeline;
@Override
@@ -170,7 +173,7 @@ public class AppController implements Initializable {
boolean walletAdded = c.getAddedSubList().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
boolean walletRemoved = c.getRemoved().stream().anyMatch(tab -> ((TabData)tab.getUserData()).getType() == TabData.TabType.WALLET);
if(walletAdded || walletRemoved) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWallets()));
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
}
List<WalletTabData> closedWalletTabs = c.getRemoved().stream().map(tab -> (TabData)tab.getUserData())
@@ -194,7 +197,7 @@ public class AppController implements Initializable {
tabs.getScene().getWindow().setOnCloseRequest(event -> {
EventManager.get().unregister(this);
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyMap()));
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList()));
});
BitcoinUnit unit = Config.get().getBitcoinUnit();
@@ -221,11 +224,15 @@ public class AppController implements Initializable {
showTxHex.setSelected(Config.get().isShowTransactionHex());
exportWallet.setDisable(true);
serverToggle.setSelected(isOnline());
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
onlineProperty().bindBidirectional(serverToggle.selectedProperty());
onlineProperty().addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
});
serverToggle.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
Config.get().setMode(serverToggle.isSelected() ? Mode.ONLINE : Mode.OFFLINE);
});
openTransactionIdItem.disableProperty().bind(onlineProperty().not());
}
@@ -451,21 +458,30 @@ public class AppController implements Initializable {
}
}
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
public List<WalletTabData> getOpenWalletTabData() {
List<WalletTabData> openWalletTabData = new ArrayList<>();
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
openWallets.put(walletTabData.getWallet(), walletTabData.getStorage());
openWalletTabData.add((WalletTabData)tabData);
}
}
return openWalletTabData;
}
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
for(WalletTabData walletTabData : getOpenWalletTabData()){
openWallets.put(walletTabData.getWallet(), walletTabData.getStorage());
}
return openWallets;
}
public void selectTab(Wallet wallet) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
@@ -521,16 +537,17 @@ public class AppController implements Initializable {
}
private void setServerToggleTooltip(Integer currentBlockHeight) {
serverToggle.setTooltip(new Tooltip(AppServices.isOnline() ? "Connected to " + Config.get().getElectrumServer() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") : "Disconnected"));
serverToggle.setTooltip(new Tooltip(AppServices.isConnected() ? "Connected to " + Config.get().getServerAddress() + (currentBlockHeight != null ? " at height " + currentBlockHeight : "") : "Disconnected"));
}
public void newWallet(ActionEvent event) {
WalletNameDialog dlg = new WalletNameDialog();
Optional<String> walletName = dlg.showAndWait();
if(walletName.isPresent()) {
File walletFile = Storage.getWalletFile(walletName.get());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = dlg.showAndWait();
if(optNameAndBirthDate.isPresent()) {
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
File walletFile = Storage.getWalletFile(nameAndBirthDate.getName());
Storage storage = new Storage(walletFile);
Wallet wallet = new Wallet(walletName.get(), PolicyType.SINGLE, ScriptType.P2WPKH);
Wallet wallet = new Wallet(nameAndBirthDate.getName(), PolicyType.SINGLE, ScriptType.P2WPKH, nameAndBirthDate.getBirthDate());
addWalletTabOrWindow(storage, wallet, false);
}
}
@@ -669,7 +686,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) {
@@ -695,8 +716,17 @@ public class AppController implements Initializable {
}
private void addImportedWallet(Wallet wallet) {
File walletFile = Storage.getExistingWallet(wallet.getName());
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName());
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
if(optNameAndBirthDate.isPresent()) {
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
wallet.setName(nameAndBirthDate.getName());
wallet.setBirthDate(nameAndBirthDate.getBirthDate());
} else {
return;
}
File walletFile = Storage.getExistingWallet(wallet.getName());
if(walletFile != null) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("Existing wallet found");
@@ -835,6 +865,8 @@ public class AppController implements Initializable {
tab.setContent(walletLoader.load());
WalletController controller = walletLoader.getController();
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);
EventManager.get().register(walletForm);
@@ -1019,6 +1051,14 @@ public class AppController implements Initializable {
return contextMenu;
}
public void setServerType(ServerType serverType) {
if(serverType == ServerType.BITCOIN_CORE && !serverToggle.getStyleClass().contains("core-server")) {
serverToggle.getStyleClass().add("core-server");
} else {
serverToggle.getStyleClass().remove("core-server");
}
}
public void setTheme(ActionEvent event) {
Theme selectedTheme = (Theme)theme.getSelectedToggle().getUserData();
if(Config.get().getTheme() != selectedTheme) {
@@ -1040,6 +1080,11 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void serverTypeChanged(ServerTypeChangedEvent event) {
setServerType(event.getServerType());
}
@Subscribe
public void tabSelected(TabSelectedEvent event) {
if(tabs.getTabs().contains(event.getTab())) {
@@ -1152,7 +1197,10 @@ public class AppController implements Initializable {
public void statusUpdated(StatusEvent event) {
statusBar.setText(event.getStatus());
PauseTransition wait = new PauseTransition(Duration.seconds(20));
if(wait != null && wait.getStatus() == Animation.Status.RUNNING) {
wait.stop();
}
wait = new PauseTransition(Duration.seconds(event.getShowDuration()));
wait.setOnFinished((e) -> {
if(statusBar.getText().equals(event.getStatus())) {
statusBar.setText("");
@@ -1225,6 +1273,56 @@ public class AppController implements Initializable {
}
}
@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);
}
}
@Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) {
serverToggle.setDisable(false);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
statusUpdated(new StatusEvent("Syncing... (" + event.getProgress() + "% complete, synced to " + event.getTipAsString() + ")"));
if(event.getProgress() > 0 && (statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING)) {
statusBar.setProgress((double)event.getProgress() / 100);
}
}
}
@Subscribe
public void bwtScanStatus(BwtScanStatusEvent event) {
serverToggle.setDisable(true);
if((AppServices.isConnecting() || AppServices.isConnected()) && !event.isCompleted()) {
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);
}
}
}
@Subscribe
public void bwtReadyStatus(BwtReadyStatusEvent event) {
serverToggle.setDisable(false);
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
}
@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 newBlock(NewBlockEvent event) {
setServerToggleTooltip(event.getHeight());
@@ -1278,7 +1376,7 @@ public class AppController implements Initializable {
@Subscribe
public void requestOpenWallets(RequestOpenWalletsEvent event) {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWallets()));
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), getOpenWalletTabData()));
}
@Subscribe
@@ -12,10 +12,7 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.net.VersionCheckService;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -24,9 +21,11 @@ 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;
import javafx.scene.control.ButtonType;
import javafx.scene.image.Image;
import javafx.scene.text.Font;
import javafx.stage.Stage;
@@ -57,7 +56,7 @@ public class AppServices {
private final MainApp application;
private final Map<Window, Map<Wallet, Storage>> walletWindows = new LinkedHashMap<>();
private final Map<Window, List<WalletTabData>> walletWindows = new LinkedHashMap<>();
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
@@ -86,7 +85,6 @@ public class AppServices {
private final ChangeListener<Boolean> onlineServicesListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean online) {
Config.get().setMode(online ? Mode.ONLINE : Mode.OFFLINE);
if(online) {
restartService(connectionService);
@@ -110,7 +108,7 @@ public class AppServices {
service.cancel();
}
if(service.getState() == Worker.State.CANCELLED) {
if(service.getState() == Worker.State.CANCELLED || service.getState() == Worker.State.FAILED) {
service.reset();
}
@@ -127,7 +125,7 @@ public class AppServices {
public void start() {
Config config = Config.get();
connectionService = createConnectionService();
if(config.getMode() == Mode.ONLINE && config.getElectrumServer() != null && !config.getElectrumServer().isEmpty()) {
if(config.getMode() == Mode.ONLINE && config.getServerAddress() != null && !config.getServerAddress().isEmpty()) {
connectionService.start();
}
@@ -146,6 +144,20 @@ public class AppServices {
onlineProperty.addListener(onlineServicesListener);
}
public void stop() {
if(connectionService != null) {
connectionService.cancel();
}
if(ratesService != null) {
ratesService.cancel();
}
if(versionCheckService != null) {
versionCheckService.cancel();
}
}
private ElectrumServer.ConnectionService createConnectionService() {
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService();
connectionService.setPeriod(new Duration(SERVER_PING_PERIOD));
@@ -159,6 +171,8 @@ public class AppServices {
});
connectionService.setOnSucceeded(successEvent -> {
connectionService.setRestartOnFailure(true);
onlineProperty.removeListener(onlineServicesListener);
onlineProperty.setValue(true);
onlineProperty.addListener(onlineServicesListener);
@@ -171,6 +185,10 @@ public class AppServices {
//Close connection here to create a new transport next time we try
connectionService.resetConnection();
if(failEvent.getSource().getException() instanceof ServerConfigException) {
connectionService.setRestartOnFailure(false);
}
onlineProperty.removeListener(onlineServicesListener);
onlineProperty.setValue(false);
onlineProperty.addListener(onlineServicesListener);
@@ -248,7 +266,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")));
@@ -261,21 +279,32 @@ public class AppServices {
}
}
public static boolean isReducedWindowHeight(Node node) {
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
}
public MainApp getApplication() {
return application;
}
public Map<Wallet, Storage> getOpenWallets(Window window) {
return walletWindows.get(window);
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
for(WalletTabData walletTabData : walletTabDataList) {
openWallets.put(walletTabData.getWallet(), walletTabData.getStorage());
}
}
return openWallets;
}
public Window getWindowForWallet(Storage storage) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().values().stream().anyMatch(storage1 -> storage1.getWalletFile().equals(storage.getWalletFile()))).map(Map.Entry::getKey).findFirst();
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getStorage().getWalletFile().equals(storage.getWalletFile()))).map(Map.Entry::getKey).findFirst();
return optWindow.orElse(null);
}
public Window getWindowForPSBT(PSBT psbt) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().keySet().stream().anyMatch(wallet -> wallet.canSign(psbt))).map(Map.Entry::getKey).findFirst();
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWallet().canSign(psbt))).map(Map.Entry::getKey).findFirst();
return optWindow.orElse(null);
}
@@ -283,8 +312,12 @@ public class AppServices {
return walletWindows.keySet().stream().mapToDouble(Window::getX).max().orElse(0d);
}
public static boolean isOnline() {
return onlineProperty.get();
public static boolean isConnecting() {
return get().connectionService != null && get().connectionService.isConnecting();
}
public static boolean isConnected() {
return onlineProperty.get() && get().connectionService != null && get().connectionService.isConnected();
}
public static BooleanProperty onlineProperty() {
@@ -335,14 +368,21 @@ public class AppServices {
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
public static void showErrorDialog(String title, String content) {
Alert alert = new Alert(Alert.AlertType.ERROR);
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
}
public static Optional<ButtonType> showErrorDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.ERROR, buttons);
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
alert.setContentText(content);
alert.showAndWait();
return alert.showAndWait();
}
public static void setStageIcon(Window window) {
@@ -365,7 +405,7 @@ public class AppServices {
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
String banner = event.getServerBanner();
String status = "Connected to " + Config.get().getElectrumServer() + " at height " + event.getBlockHeight();
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
EventManager.get().post(new StatusEvent(status));
}
@@ -406,11 +446,13 @@ public class AppServices {
@Subscribe
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
ratesService.cancel();
if(ratesService != null) {
ratesService.cancel();
if(Config.get().getMode() != Mode.OFFLINE && event.getExchangeSource() != ExchangeSource.NONE) {
ratesService = createRatesService(event.getExchangeSource(), event.getCurrency());
ratesService.start();
if(Config.get().getMode() != Mode.OFFLINE && event.getExchangeSource() != ExchangeSource.NONE) {
ratesService = createRatesService(event.getExchangeSource(), event.getCurrency());
ratesService.start();
}
}
}
@@ -431,25 +473,25 @@ public class AppServices {
@Subscribe
public void openWallets(OpenWalletsEvent event) {
if(event.getWalletsMap().isEmpty()) {
if(event.getWalletTabDataList().isEmpty()) {
walletWindows.remove(event.getWindow());
} else {
walletWindows.put(event.getWindow(), event.getWalletsMap());
walletWindows.put(event.getWindow(), event.getWalletTabDataList());
}
List<Map.Entry<Wallet, Storage>> allWallets = walletWindows.values().stream().flatMap(map -> map.entrySet().stream()).collect(Collectors.toList());
List<WalletTabData> allWallets = walletWindows.values().stream().flatMap(Collection::stream).collect(Collectors.toList());
Platform.runLater(() -> {
if(!Window.getWindows().isEmpty()) {
List<File> walletFiles = allWallets.stream().map(entry -> entry.getValue().getWalletFile()).collect(Collectors.toList());
List<File> walletFiles = allWallets.stream().map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
Config.get().setRecentWalletFiles(walletFiles);
}
});
boolean usbWallet = false;
for(Map.Entry<Wallet, Storage> entry : allWallets) {
Wallet wallet = entry.getKey();
Storage storage = entry.getValue();
for(WalletTabData walletTabData : allWallets) {
Wallet wallet = walletTabData.getWallet();
Storage storage = walletTabData.getStorage();
if(!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB)) {
usbWallet = true;
@@ -485,4 +527,28 @@ public class AppServices {
public void requestDisconnect(RequestDisconnectEvent event) {
onlineProperty.set(false);
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
restartBwt(event.getWallet());
}
@Subscribe
public void walletOpening(WalletOpeningEvent event) {
restartBwt(event.getWallet());
}
private void restartBwt(Wallet wallet) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && isConnected() && wallet.isValid()) {
connectionService.cancel();
}
}
@Subscribe
public void bwtShutdown(BwtShutdownEvent event) {
if(onlineProperty().get() && !connectionService.isRunning()) {
connectionService.reset();
connectionService.start();
}
}
}
@@ -9,14 +9,11 @@ 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.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.text.Font;
import javafx.stage.Stage;
import org.controlsfx.glyphfont.GlyphFontRegistry;
@@ -28,10 +25,8 @@ import java.util.*;
import java.util.stream.Collectors;
public class MainApp extends Application {
private static final Logger log = LoggerFactory.getLogger(MainApp.class);
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "0.9.9";
public static final String APP_VERSION = "1.0.0";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -51,6 +46,8 @@ public class MainApp extends Application {
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
AppServices.initialize(this);
boolean createNewWallet = false;
Mode mode = Config.get().getMode();
if(mode == null) {
@@ -59,6 +56,7 @@ public class MainApp extends Application {
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);
@@ -68,7 +66,13 @@ public class MainApp extends Application {
}
}
AppServices.initialize(this);
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("");
}
AppController appController = AppServices.newAppWindow(stage);
if(createNewWallet) {
@@ -96,6 +100,7 @@ public class MainApp extends Application {
@Override
public void stop() throws Exception {
AppServices.get().stop();
mainStage.close();
}
@@ -109,8 +114,8 @@ public class MainApp extends Application {
}
if(args.dir != null) {
log.info("Using configured Sparrow home folder of " + args.dir);
System.setProperty(APP_HOME_PROPERTY, args.dir);
getLogger().info("Using configured Sparrow home folder of " + args.dir);
}
if(args.network != null) {
@@ -121,7 +126,7 @@ public class MainApp extends Application {
try {
Network.set(Network.valueOf(envNetwork.toUpperCase()));
} catch(Exception e) {
log.warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
getLogger().warn("Invalid " + NETWORK_ENV_PROPERTY + " property: " + envNetwork);
}
}
}
@@ -132,9 +137,13 @@ public class MainApp extends Application {
}
if(Network.get() != Network.MAINNET) {
log.info("Using " + Network.get() + " configuration");
getLogger().info("Using " + Network.get() + " configuration");
}
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
}
private static Logger getLogger() {
return LoggerFactory.getLogger(MainApp.class);
}
}
@@ -2,12 +2,27 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.wallet.Entry;
import javafx.application.Platform;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableView;
import javafx.scene.layout.StackPane;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Optional;
public class CoinTreeTable extends TreeTableView<Entry> {
private BitcoinUnit bitcoinUnit;
@@ -51,10 +66,46 @@ public class CoinTreeTable extends TreeTableView<Entry> {
setPlaceholder(new Label("Loading transactions..."));
}
} else {
setPlaceholder(new Label("No transactions"));
setPlaceholder(getDefaultPlaceholder(event.getWallet()));
}
});
}
}
}
protected Node getDefaultPlaceholder(Wallet wallet) {
StackPane stackPane = new StackPane();
stackPane.getChildren().add(AppServices.isConnecting() ? new Label("Loading transactions...") : new Label("No transactions"));
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && !AppServices.isConnecting()) {
Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> {
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()));
}
}
});
if(wallet.getBirthDate() == null) {
hyperlink.setText("Scan for previous transactions?");
} else {
DateFormat dateFormat = new SimpleDateFormat(DateStringConverter.FORMAT_PATTERN);
hyperlink.setText("Scan for transactions earlier than " + dateFormat.format(wallet.getBirthDate()) + "?");
}
stackPane.getChildren().add(hyperlink);
}
stackPane.setAlignment(Pos.CENTER);
return stackPane;
}
}
@@ -0,0 +1,29 @@
package com.sparrowwallet.sparrow.control;
import javafx.util.StringConverter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class DateStringConverter extends StringConverter<LocalDate> {
public static final String FORMAT_PATTERN = "yyyy/MM/dd";
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(FORMAT_PATTERN);
@Override
public String toString(LocalDate date) {
if (date != null) {
return DATE_FORMATTER.format(date);
} else {
return "";
}
}
@Override
public LocalDate fromString(String string) {
if (string != null && !string.isEmpty()) {
return LocalDate.parse(string, DATE_FORMATTER);
} else {
return null;
}
}
}
@@ -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() {
@@ -73,18 +73,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);
}
@@ -104,13 +93,7 @@ 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);
}
@@ -130,13 +113,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);
}
@@ -156,15 +133,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
@@ -24,6 +24,7 @@ import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Borders;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
@@ -48,6 +49,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private Label invalidLabel;
private Button calculateButton;
private Button backButton;
private Button nextButton;
private Button confirmButton;
private List<String> generatedMnemonicCode;
@@ -197,9 +199,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
confirmButton.managedProperty().bind(confirmButton.visibleProperty());
confirmButton.setVisible(false);
confirmButton.setDefaultButton(true);
confirmButton.setTooltip(new Tooltip("Write down the words above as a backup - you will need to re-enter them to confirm your backup is correct"));
buttonPane.getChildren().add(confirmButton);
AnchorPane.setRightAnchor(confirmButton, 0.0);
confirmButton.setTooltip(new Tooltip("Re-enter the generated word list to confirm your backup is correct"));
calculateButton = new Button("Calculate Seed");
calculateButton.setDisable(true);
@@ -218,6 +218,15 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
backButton.setTooltip(new Tooltip("Go back to the generated word list"));
backButton.setVisible(false);
nextButton = new Button("Confirm Backup...");
nextButton.setOnAction(event -> {
confirmRecord();
});
nextButton.managedProperty().bind(nextButton.visibleProperty());
nextButton.setTooltip(new Tooltip("Confirm you have recorded the generated word list"));
nextButton.setVisible(false);
nextButton.setDefaultButton(true);
wordEntriesProperty.addListener((ListChangeListener<String>) c -> {
boolean empty = true;
boolean validWords = true;
@@ -249,7 +258,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
HBox rightBox = new HBox();
rightBox.setSpacing(10);
rightBox.getChildren().addAll(backButton, calculateButton);
rightBox.getChildren().addAll(backButton, nextButton, confirmButton, calculateButton);
buttonPane.getChildren().add(rightBox);
AnchorPane.setRightAnchor(rightBox, 0.0);
@@ -257,7 +266,9 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
vBox.getChildren().add(buttonPane);
}
return vBox;
StackPane stackPane = new StackPane();
stackPane.getChildren().add(vBox);
return stackPane;
}
private void generateNew() {
@@ -275,7 +286,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
showHideLink.setVisible(false);
calculateButton.setVisible(false);
confirmButton.setVisible(true);
nextButton.setVisible(true);
backButton.setVisible(false);
if(generatedMnemonicCode.size() != wordsPane.getChildren().size()) {
@@ -287,6 +298,36 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
wordEntry.getEditor().setText(generatedMnemonicCode.get(i));
wordEntry.getEditor().setEditable(false);
}
StackPane wordsStackPane = (StackPane)getContent();
if(wordsStackPane.getChildren().size() > 1) {
wordsStackPane.getChildren().remove(1);
confirmButton.setVisible(false);
}
}
private void confirmRecord() {
setDescription("Confirm words have been recorded");
showHideLink.setVisible(false);
StackPane wordsPane = (StackPane)getContent();
StackPane confirmPane = new StackPane();
confirmPane.setMaxWidth(300);
confirmPane.setMaxHeight(100);
Region region = new Region();
region.setMinWidth(confirmPane.getMaxWidth());
region.setMinHeight(confirmPane.getMaxHeight());
confirmPane.getStyleClass().add("box-overlay");
Node wrappedRegion = Borders.wrap(region).lineBorder().innerPadding(0).outerPadding(0).buildAll();
Label label = new Label("Have these " + wordEntriesProperty.get().size() + " words been written down?\nIn the next step, you will need to re-enter them.");
confirmPane.getChildren().addAll(wrappedRegion, label);
wordsPane.getChildren().add(confirmPane);
setExpanded(true);
backButton.setVisible(true);
nextButton.setVisible(false);
confirmButton.setVisible(true);
generateButton.setVisible(false);
}
private void confirmBackup() {
@@ -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;
@@ -61,7 +61,7 @@ public class TitledDescriptionPane extends TitledPane {
descriptionLabel = new Label(description);
descriptionLabel.getStyleClass().add("description-label");
showHideLink = new Hyperlink("Show Details...");
showHideLink = new Hyperlink("Details...");
showHideLink.managedProperty().bind(showHideLink.visibleProperty());
showHideLink.setOnAction(event -> {
setExpanded(!this.isExpanded());
@@ -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());
@@ -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);
@@ -5,7 +5,6 @@ import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.Label;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
@@ -51,7 +50,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
balanceCol.setSortable(true);
getColumns().add(balanceCol);
setPlaceholder(new Label("No transactions"));
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
@@ -3,7 +3,6 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.scene.control.Label;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
@@ -69,7 +68,7 @@ public class UtxosTreeTable extends CoinTreeTable {
getColumns().add(amountCol);
setTreeColumn(amountCol);
setPlaceholder(new Label("No unspent outputs"));
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
setEditable(true);
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
@@ -0,0 +1,68 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
public class WalletBirthDateDialog extends Dialog<Date> {
private final DatePicker birthDatePicker;
public WalletBirthDateDialog(Date birthDate) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Wallet Birth Date");
dialogPane.setHeaderText("Select an approximate date earlier than the first wallet transaction:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(420);
dialogPane.setPrefHeight(200);
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HISTORY);
wallet.setFontSize(50);
dialogPane.setGraphic(wallet);
HBox datePickerBox = new HBox(10);
Label label = new Label("Start scanning from:");
label.setPadding(new Insets(5, 0, 0, 8));
datePickerBox.getChildren().add(label);
birthDatePicker = birthDate == null ? new DatePicker() : new DatePicker(birthDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
birthDatePicker.setEditable(false);
birthDatePicker.setConverter(new DateStringConverter());
datePickerBox.getChildren().add(birthDatePicker);
dialogPane.setContent(datePickerBox);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.registerValidator(birthDatePicker, Validator.combine(
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", newValue == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> birthDatePicker.getValue() == null, birthDatePicker.valueProperty());
okButton.disableProperty().bind(isInvalid);
setResultConverter(dialogButton -> dialogButton == okButtonType ? Date.from(birthDatePicker.getValue().atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) : null);
}
}
@@ -39,7 +39,7 @@ 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);
@@ -2,11 +2,15 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
@@ -16,28 +20,74 @@ import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
public class WalletNameDialog extends Dialog<String> {
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;
public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate> {
private final CustomTextField name;
private final CheckBox existingCheck;
private final DatePicker existingPicker;
public WalletNameDialog() {
this.name = (CustomTextField)TextFields.createClearableTextField();
this("");
}
public WalletNameDialog(String initialName) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
setTitle("Wallet Name");
dialogPane.setHeaderText("Enter a name for this wallet:");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(200);
dialogPane.setPrefWidth(460);
dialogPane.setPrefHeight(requestBirthDate ? 250 : 200);
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
wallet.setFontSize(50);
dialogPane.setGraphic(wallet);
final VBox content = new VBox(10);
final VBox content = new VBox(20);
name = (CustomTextField)TextFields.createClearableTextField();
name.setText(initialName);
content.getChildren().add(name);
HBox existingBox = new HBox(10);
existingCheck = new CheckBox("Has existing transactions");
existingCheck.setPadding(new Insets(5, 0, 0, 0));
existingBox.getChildren().add(existingCheck);
existingPicker = new DatePicker();
existingPicker.setConverter(new DateStringConverter());
existingPicker.setEditable(false);
existingPicker.setPrefWidth(130);
existingPicker.managedProperty().bind(existingPicker.visibleProperty());
existingPicker.setVisible(false);
existingBox.getChildren().add(existingPicker);
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("Select an approximate date earlier than the first wallet transaction.");
helpLabel.setTranslateY(5);
helpLabel.managedProperty().bind(helpLabel.visibleProperty());
helpLabel.visibleProperty().bind(existingPicker.visibleProperty());
existingBox.getChildren().add(helpLabel);
existingCheck.selectedProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
existingCheck.setText("Has existing transactions starting from");
existingPicker.setVisible(true);
} else {
existingCheck.setText("Has existing transactions");
existingPicker.setVisible(false);
}
});
if(requestBirthDate) {
content.getChildren().add(existingBox);
}
dialogPane.setContent(content);
ValidationSupport validationSupport = new ValidationSupport();
@@ -46,18 +96,39 @@ public class WalletNameDialog extends Dialog<String> {
Validator.createEmptyValidator("Wallet name is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Wallet name is not unique", Storage.walletExists(newValue))
));
validationSupport.registerValidator(existingPicker, Validator.combine(
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", existingCheck.isSelected() && newValue == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("New Wallet", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
name.getText().length() == 0 || Storage.walletExists(name.getText()), name.textProperty());
name.getText().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
okButton.disableProperty().bind(isInvalid);
name.setPromptText("Wallet Name");
Platform.runLater(name::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? name.getText() : null);
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText(), existingPicker.getValue()) : null);
}
public static class NameAndBirthDate {
private final String name;
private final Date birthDate;
public NameAndBirthDate(String name, LocalDate birthLocalDate) {
this.name = name;
this.birthDate = (birthLocalDate == null ? null : Date.from(birthLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()));
}
public String getName() {
return name;
}
public Date getBirthDate() {
return birthDate;
}
}
}
@@ -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;
}
}
@@ -2,6 +2,7 @@ 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.*;
@@ -13,13 +14,10 @@ import org.controlsfx.control.StatusBar;
import org.controlsfx.control.ToggleSwitch;
public class WelcomeDialog extends Dialog<Mode> {
private static final String[] ELECTRUM_SERVERS = new String[]{
"ElectrumX (Recommended)", "https://github.com/spesmilo/electrumx",
"electrs", "https://github.com/romanz/electrs",
"esplora-electrs", "https://github.com/Blockstream/electrs"};
private final HostServices hostServices;
private ServerType serverType = ServerType.ELECTRUM_SERVER;
public WelcomeDialog(HostServices services) {
this.hostServices = services;
@@ -27,10 +25,11 @@ public class WelcomeDialog extends Dialog<Mode> {
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(480);
dialogPane.setPrefHeight(520);
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if (!image.isError()) {
@@ -46,16 +45,10 @@ public class WelcomeDialog extends Dialog<Mode> {
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 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("For privacy and security reasons it is not recommended to use a public Electrum server. Install an Electrum server that connects to your full node to index the blockchain and provide full privacy. Examples include:"));
VBox linkBox = new VBox();
for(int i = 0; i < ELECTRUM_SERVERS.length; i+=2) {
linkBox.getChildren().add(createBulletedLink(ELECTRUM_SERVERS[i], ELECTRUM_SERVERS[i+1]));
}
content.getChildren().add(linkBox);
content.getChildren().add(createParagraph("You can change your mode at any time using the toggle in the status bar:"));
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);
@@ -70,16 +63,6 @@ public class WelcomeDialog extends Dialog<Mode> {
return label;
}
private HyperlinkLabel createBulletedLink(String name, String url) {
String[] nameParts = name.split(" ");
HyperlinkLabel label = new HyperlinkLabel(" \u2022 [" + nameParts[0] + "] " + (nameParts.length > 1 ? nameParts[1] : ""));
label.setOnAction(event -> {
hostServices.showDocument(url);
});
return label;
}
private StatusBar createStatusBar(ButtonType onlineButtonType, ButtonType offlineButtonType) {
StatusBar statusBar = new StatusBar();
statusBar.setText("Online Mode");
@@ -97,7 +80,18 @@ public class WelcomeDialog extends Dialog<Mode> {
onlineButton.setDefaultButton(newValue);
Button offlineButton = (Button) getDialogPane().lookupButton(offlineButtonType);
offlineButton.setDefaultButton(!newValue);
statusBar.setText(newValue ? "Online Mode" : "Offline Mode");
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);
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.event;
public class BwtBootStatusEvent extends BwtStatusEvent {
public BwtBootStatusEvent(String status) {
super(status);
}
}
@@ -0,0 +1,14 @@
package com.sparrowwallet.sparrow.event;
public class BwtElectrumReadyStatusEvent extends BwtStatusEvent {
private final String electrumAddr;
public BwtElectrumReadyStatusEvent(String status, String electrumAddr) {
super(status);
this.electrumAddr = electrumAddr;
}
public String getElectrumAddr() {
return electrumAddr;
}
}
@@ -0,0 +1,7 @@
package com.sparrowwallet.sparrow.event;
public class BwtReadyStatusEvent extends BwtStatusEvent {
public BwtReadyStatusEvent(String status) {
super(status);
}
}
@@ -0,0 +1,38 @@
package com.sparrowwallet.sparrow.event;
import java.time.Duration;
public class BwtScanStatusEvent extends BwtStatusEvent {
private final int progress;
private final Duration remainingDuration;
public BwtScanStatusEvent(String status, int progress, Duration remainingDuration) {
super(status);
this.progress = progress;
this.remainingDuration = remainingDuration;
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Duration getRemaining() {
return remainingDuration;
}
public String getRemainingAsString() {
if(remainingDuration != null) {
if(progress < 30) {
return Math.round((double)remainingDuration.toSeconds() / 60) + "m";
} else {
return remainingDuration.toMinutesPart() + "m " + remainingDuration.toSecondsPart() + "s";
}
}
return "";
}
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
/**
* Empty class used to notify the bwt has shut down.
* Note this extends from DisconnectionEvent, which is the more general event fired on any type of disconnection.
*/
public class BwtShutdownEvent extends DisconnectionEvent {
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class BwtStatusEvent {
private final String status;
public BwtStatusEvent(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
}
@@ -0,0 +1,34 @@
package com.sparrowwallet.sparrow.event;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
public class BwtSyncStatusEvent extends BwtStatusEvent {
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy/MM/dd HH:mm");
private final int progress;
private final Date tip;
public BwtSyncStatusEvent(String status, int progress, Date tip) {
super(status);
this.progress = progress;
this.tip = tip;
}
public int getProgress() {
return progress;
}
public boolean isCompleted() {
return progress == 100;
}
public Date getTip() {
return tip;
}
public String getTipAsString() {
return tip == null ? "" : DATE_FORMAT.format(tip);
}
}
@@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.event;
/**
* Empty class used to signal that the server has been disconnected from.
*/
public class DisconnectionEvent {
}
@@ -1,35 +1,47 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.stage.Window;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class OpenWalletsEvent {
private final Window window;
private final Map<Wallet, Storage> walletsMap;
private final List<WalletTabData> walletTabDataList;
public OpenWalletsEvent(Window window, Map<Wallet, Storage> walletsMap) {
public OpenWalletsEvent(Window window, List<WalletTabData> walletTabDataList) {
this.window = window;
this.walletsMap = walletsMap;
this.walletTabDataList = walletTabDataList;
}
public Window getWindow() {
return window;
}
public List<Wallet> getWallets() {
return new ArrayList<>(walletsMap.keySet());
}
public Storage getStorage(Wallet wallet) {
return walletsMap.get(wallet);
public List<WalletTabData> getWalletTabDataList() {
return walletTabDataList;
}
public Map<Wallet, Storage> getWalletsMap() {
return walletsMap;
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
for(WalletTabData walletTabData : walletTabDataList){
openWallets.put(walletTabData.getWallet(), walletTabData.getStorage());
}
return openWallets;
}
public List<Wallet> getWallets() {
return new ArrayList<>(getWalletsMap().keySet());
}
public Storage getStorage(Wallet wallet) {
return getWalletsMap().get(wallet);
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.net.ServerType;
public class ServerTypeChangedEvent {
private final ServerType serverType;
public ServerTypeChangedEvent(ServerType serverType) {
this.serverType = serverType;
}
public ServerType getServerType() {
return serverType;
}
}
@@ -20,6 +20,6 @@ public class SettingsChangedEvent {
}
public enum Type {
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT;
POLICY, SCRIPT_TYPE, MUTLISIG_THRESHOLD, MULTISIG_TOTAL, KEYSTORE_LABEL, KEYSTORE_FINGERPRINT, KEYSTORE_DERIVATION, KEYSTORE_XPUB, GAP_LIMIT, BIRTH_DATE;
}
}
@@ -1,13 +1,25 @@
package com.sparrowwallet.sparrow.event;
public class StatusEvent {
public static final int DEFAULT_SHOW_DURATION_SECS = 20;
private final String status;
private final int showDuration;
public StatusEvent(String status) {
this(status, DEFAULT_SHOW_DURATION_SECS);
}
public StatusEvent(String status, int showDuration) {
this.status = status;
this.showDuration = showDuration;
}
public String getStatus() {
return status;
}
public int getShowDuration() {
return showDuration;
}
}
@@ -3,7 +3,9 @@ package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.io.Storage;
import java.io.File;
import java.util.List;
import java.util.stream.Collectors;
@@ -12,13 +14,19 @@ import java.util.stream.Collectors;
*
*/
public class WalletHistoryChangedEvent extends WalletChangedEvent {
private final Storage storage;
private final List<WalletNode> historyChangedNodes;
public WalletHistoryChangedEvent(Wallet wallet, List<WalletNode> historyChangedNodes) {
public WalletHistoryChangedEvent(Wallet wallet, Storage storage, List<WalletNode> historyChangedNodes) {
super(wallet);
this.storage = storage;
this.historyChangedNodes = historyChangedNodes;
}
public File getWalletFile() {
return storage.getWalletFile();
}
public List<WalletNode> getHistoryChangedNodes() {
return historyChangedNodes;
}
@@ -4,27 +4,27 @@ import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryStatusEvent {
private final Wallet wallet;
private final boolean loaded;
private final boolean loading;
private final String statusMessage;
private final String errorMessage;
public WalletHistoryStatusEvent(Wallet wallet, boolean loaded) {
public WalletHistoryStatusEvent(Wallet wallet, boolean loading) {
this.wallet = wallet;
this.loaded = loaded;
this.loading = loading;
this.statusMessage = null;
this.errorMessage = null;
}
public WalletHistoryStatusEvent(Wallet wallet,boolean loaded, String statusMessage) {
public WalletHistoryStatusEvent(Wallet wallet, boolean loading, String statusMessage) {
this.wallet = wallet;
this.loaded = false;
this.loading = loading;
this.statusMessage = statusMessage;
this.errorMessage = null;
}
public WalletHistoryStatusEvent(Wallet wallet,String errorMessage) {
public WalletHistoryStatusEvent(Wallet wallet, String errorMessage) {
this.wallet = wallet;
this.loaded = false;
this.loading = true;
this.statusMessage = null;
this.errorMessage = errorMessage;
}
@@ -34,11 +34,7 @@ public class WalletHistoryStatusEvent {
}
public boolean isLoading() {
return !loaded;
}
public boolean isLoaded() {
return loaded;
return loading;
}
public String getStatusMessage() {
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.io.Storage;
public class WalletOpeningEvent {
private final Storage storage;
private final Wallet wallet;
public WalletOpeningEvent(Storage storage, Wallet wallet) {
this.storage = storage;
this.wallet = wallet;
}
public Storage getStorage() {
return storage;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -31,6 +31,7 @@ public class FontAwesome5 extends GlyphFont {
EYE('\uf06e'),
HAND_HOLDING('\uf4bd'),
HAND_HOLDING_MEDICAL('\ue05c'),
HISTORY('\uf1da'),
KEY('\uf084'),
LAPTOP('\uf109'),
LOCK('\uf023'),
@@ -44,7 +45,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');
@@ -11,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.*;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
@Override
@@ -32,7 +33,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
Gson gson = new Gson();
CoboVaultSinglesigKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream), CoboVaultSinglesigKeystore.class);
CoboVaultSinglesigKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), CoboVaultSinglesigKeystore.class);
if(coboKeystore.MasterFingerprint == null || coboKeystore.AccountKeyPath == null || coboKeystore.ExtPubKey == null) {
throw new ImportException("Not a valid " + getName() + " keystore export");
@@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -30,7 +31,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
InputStreamReader reader = new InputStreamReader(inputStream);
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
ColdcardKeystore cck = Storage.getGson().fromJson(reader, ColdcardKeystore.class);
Keystore keystore = new Keystore("Coldcard");
@@ -48,8 +49,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2sh_deriv));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2sh));
} else if(scriptType.equals(ScriptType.P2SH_P2WSH)) {
keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2wsh_p2sh_deriv));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2wsh_p2sh));
keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2wsh_p2sh_deriv != null ? cck.p2wsh_p2sh_deriv : cck.p2sh_p2wsh_deriv));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2wsh_p2sh != null ? cck.p2wsh_p2sh : cck.p2sh_p2wsh));
} else if(scriptType.equals(ScriptType.P2WSH)) {
keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2wsh_deriv));
keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2wsh));
@@ -65,6 +66,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
public String p2sh;
public String p2wsh_p2sh_deriv;
public String p2wsh_p2sh;
public String p2sh_p2wsh_deriv;
public String p2sh_p2wsh;
public String p2wsh_deriv;
public String p2wsh;
public String xpub;
@@ -92,7 +95,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
String derivation = null;
try {
List<String> lines = CharStreams.readLines(new InputStreamReader(inputStream));
List<String> lines = CharStreams.readLines(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
for (String line : lines) {
line = line.trim();
if (line.isEmpty()) {
@@ -14,6 +14,7 @@ import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
@@ -53,10 +54,10 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
Gson gson = new Gson();
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
}.getType();
Map<String, JsonElement> map = gson.fromJson(new InputStreamReader(inputStream), stringStringMap);
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();
@@ -66,7 +67,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
ColdcardKeystore ck = gson.fromJson(map.get(key), ColdcardKeystore.class);
if(ck.name != null) {
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").toUpperCase());
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").replace("p2sh-p2wpkh", "p2sh_p2wpkh").toUpperCase());
if(ckScriptType.equals(scriptType)) {
Keystore keystore = new Keystore();
keystore.setLabel(getName());
@@ -106,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,8 +4,10 @@ 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.wallet.FeeRatesSelection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,6 +39,14 @@ public class Config {
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private File hwi;
private boolean hdCapture;
private ServerType serverType;
private String coreServer;
private CoreAuthType coreAuthType;
private File coreDataDir;
private String coreAuth;
private Boolean coreMultiWallet;
private String coreWallet;
private String electrumServer;
private File electrumServerCert;
private boolean useProxy;
@@ -241,6 +251,82 @@ public class Config {
flush();
}
public boolean isHdCapture() {
return hdCapture;
}
public void setHdCapture(boolean hdCapture) {
this.hdCapture = hdCapture;
flush();
}
public ServerType getServerType() {
return serverType;
}
public void setServerType(ServerType serverType) {
this.serverType = serverType;
flush();
}
public String getServerAddress() {
return getServerType() == ServerType.BITCOIN_CORE ? getCoreServer() : getElectrumServer();
}
public String getCoreServer() {
return coreServer;
}
public void setCoreServer(String coreServer) {
this.coreServer = coreServer;
flush();
}
public CoreAuthType getCoreAuthType() {
return coreAuthType;
}
public void setCoreAuthType(CoreAuthType coreAuthType) {
this.coreAuthType = coreAuthType;
flush();
}
public File getCoreDataDir() {
return coreDataDir;
}
public void setCoreDataDir(File coreDataDir) {
this.coreDataDir = coreDataDir;
flush();
}
public String getCoreAuth() {
return coreAuth;
}
public void setCoreAuth(String coreAuth) {
this.coreAuth = coreAuth;
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;
flush();
}
public String getElectrumServer() {
return electrumServer;
}
@@ -277,12 +363,13 @@ public class Config {
flush();
}
private void flush() {
private synchronized void flush() {
Gson gson = getGson();
try {
File configFile = getConfigFile();
Writer writer = new FileWriter(configFile);
gson.toJson(this, writer);
writer.flush();
writer.close();
} catch (IOException e) {
//Ignore
@@ -58,9 +58,9 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
Reader reader;
if(password != null) {
ECKey decryptionKey = Pbkdf2KeyDeriver.DEFAULT_INSTANCE.deriveECKey(password);
reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(inputStream, decryptionKey)));
reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(inputStream, decryptionKey)), StandardCharsets.UTF_8);
} else {
reader = new InputStreamReader(inputStream);
reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
}
try {
@@ -173,6 +173,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);
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.io;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
public class IOUtils {
@@ -13,7 +14,7 @@ public class IOUtils {
}
if(file.exists()) {
try(BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file)))) {
try(BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line = br.readLine();
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
@@ -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();
}
}
@@ -12,12 +12,13 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class SpecterDIY implements KeystoreFileImport {
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
String text = CharStreams.toString(new InputStreamReader(inputStream));
String text = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
String outputDesc = "sh(" + text + ")";
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(outputDesc);
Wallet wallet = outputDescriptor.toWallet();
@@ -51,7 +51,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
try {
Gson gson = new Gson();
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream), SpecterWallet.class);
SpecterWallet specterWallet = gson.fromJson(new InputStreamReader(inputStream, StandardCharsets.UTF_8), SpecterWallet.class);
if(specterWallet.descriptor != null) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(specterWallet.descriptor);
@@ -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) {
@@ -74,7 +74,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<String, ScriptHashTx[]> getScriptHashHistory(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes, boolean failOnError) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, ScriptHashTx[]> batchRequest = client.createBatchRequest().keysType(String.class).returnType(ScriptHashTx[].class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Loading transactions"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions"));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.get_history", pathScriptHashes.get(path));
@@ -130,7 +130,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Finding transactions"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions"));
for(String path : pathScriptHashes.keySet()) {
batchRequest.add(path, "blockchain.scripthash.subscribe", pathScriptHashes.get(path));
@@ -151,7 +151,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<Integer, String> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving blocks"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving blocks"));
for(Integer height : blockHeights) {
batchRequest.add(height, "blockchain.block.header", height);
@@ -171,7 +171,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
public Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids) {
JsonRpcClient client = new JsonRpcClient(transport);
BatchRequestBuilder<String, String> batchRequest = client.createBatchRequest().keysType(String.class).returnType(String.class);
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving transactions"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving transactions"));
for(String txid : txids) {
batchRequest.add(txid, "blockchain.transaction.get", txid);
@@ -204,7 +204,8 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
try {
return new RetryLogic<Map<String, VerboseTransaction>>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
//The server may return an error if the transaction has not yet been broadcasted - this is a valid state so only try once
return new RetryLogic<Map<String, VerboseTransaction>>(1, RETRY_DELAY, IllegalStateException.class).getResult(batchRequest::execute);
} catch(JsonRpcBatchException e) {
log.warn("Some errors retrieving transactions: " + e.getErrors());
return (Map<String, VerboseTransaction>)e.getSuccesses();
@@ -0,0 +1,323 @@
package com.sparrowwallet.sparrow.net;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import dev.bwt.libbwt.daemon.CallbackNotifier;
import dev.bwt.libbwt.daemon.NativeBwtDaemon;
import javafx.application.Platform;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.*;
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;
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);
}
}
}
private void start(CallbackNotifier callback) {
start(Collections.emptyList(), null, null, null, callback);
}
private void start(Collection<Wallet> wallets, CallbackNotifier callback) {
List<Wallet> validWallets = wallets.stream().filter(Wallet::isValid).collect(Collectors.toList());
List<String> outputDescriptors = new ArrayList<>();
for(Wallet wallet : validWallets) {
OutputDescriptor receiveOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE);
outputDescriptors.add(receiveOutputDescriptor.toString(false, false));
OutputDescriptor changeOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.CHANGE);
outputDescriptors.add(changeOutputDescriptor.toString(false, false));
}
int rescanSince = validWallets.stream().filter(wallet -> wallet.getBirthDate() != null).mapToInt(wallet -> (int)(wallet.getBirthDate().getTime() / 1000)).min().orElse(-1);
int gapLimit = validWallets.stream().filter(wallet -> wallet.getGapLimit() > 0).mapToInt(Wallet::getGapLimit).max().orElse(Wallet.DEFAULT_LOOKAHEAD);
boolean forceRescan = false;
for(Wallet wallet : validWallets) {
Date txBirthDate = wallet.getTransactions().values().stream().map(BlockTransactionHash::getDate).filter(Objects::nonNull).min(Date::compareTo).orElse(null);
if((wallet.getBirthDate() != null && txBirthDate != null && wallet.getBirthDate().before(txBirthDate)) || (txBirthDate == null && wallet.getStoredBlockHeight() != null && wallet.getStoredBlockHeight() == 0)) {
forceRescan = true;
}
}
start(outputDescriptors, rescanSince, forceRescan, gapLimit, callback);
}
/**
* Start the bwt daemon with the provided wallets
* Blocks until the daemon is shut down.
*
* @param outputDescriptors descriptors of keys to add to Bitcoin Core
* @param rescanSince seconds since epoch to start scanning keys
* @param gapLimit desired gap limit beyond last used address
* @param callback object receiving notifications
*/
private void start(List<String> outputDescriptors, Integer rescanSince, Boolean forceRescan, Integer gapLimit, CallbackNotifier callback) {
BwtConfig bwtConfig = new BwtConfig();
bwtConfig.network = Network.get() == Network.MAINNET ? "bitcoin" : Network.get().getName();
if(!outputDescriptors.isEmpty()) {
bwtConfig.descriptors = outputDescriptors;
bwtConfig.rescanSince = (rescanSince == null || rescanSince < 0 ? "now" : rescanSince);
bwtConfig.forceRescan = forceRescan;
bwtConfig.gapLimit = gapLimit;
} else {
bwtConfig.requireAddresses = false;
}
bwtConfig.verbose = log.isDebugEnabled() ? 2 : 0;
if(!log.isDebugEnabled()) {
bwtConfig.setupLogger = false;
}
bwtConfig.electrumAddr = "127.0.0.1:0";
bwtConfig.electrumSkipMerkle = true;
Config config = Config.get();
bwtConfig.bitcoindUrl = config.getCoreServer();
if(config.getCoreAuthType() == CoreAuthType.COOKIE && config.getCoreDataDir() != null) {
bwtConfig.bitcoindDir = config.getCoreDataDir().getAbsolutePath() + "/";
} else {
bwtConfig.bitcoindAuth = config.getCoreAuth();
}
if(config.getCoreMultiWallet() != Boolean.FALSE) {
bwtConfig.bitcoindWallet = config.getCoreWallet();
}
bwtConfig.createWalletIfMissing = true;
Gson gson = new Gson();
String jsonConfig = gson.toJson(bwtConfig);
log.debug("Configuring bwt: " + jsonConfig);
NativeBwtDaemon.start(jsonConfig, callback);
}
/**
* Shut down the BWT daemon
*
*/
private void shutdown() {
if(shutdownPtr == null) {
terminating = true;
return;
}
NativeBwtDaemon.shutdown(shutdownPtr);
this.terminating = false;
this.ready = false;
this.shutdownPtr = null;
Platform.runLater(() -> EventManager.get().post(new BwtShutdownEvent()));
}
public boolean isRunning() {
return shutdownPtr != null;
}
public boolean isReady() {
return ready;
}
public boolean isTerminating() {
return terminating;
}
public ConnectionService getConnectionService(Collection<Wallet> wallets) {
return wallets != null ? new ConnectionService(wallets) : new ConnectionService();
}
public DisconnectionService getDisconnectionService() {
return new DisconnectionService();
}
private static class BwtConfig {
@SerializedName("network")
public String network;
@SerializedName("bitcoind_url")
public String bitcoindUrl;
@SerializedName("bitcoind_auth")
public String bitcoindAuth;
@SerializedName("bitcoind_dir")
public String bitcoindDir;
@SerializedName("bitcoind_cookie")
public String bitcoindCookie;
@SerializedName("bitcoind_wallet")
public String bitcoindWallet;
@SerializedName("create_wallet_if_missing")
public Boolean createWalletIfMissing;
@SerializedName("descriptors")
public List<String> descriptors;
@SerializedName("xpubs")
public String xpubs;
@SerializedName("rescan_since")
public Object rescanSince;
@SerializedName("force_rescan")
public Boolean forceRescan;
@SerializedName("gap_limit")
public Integer gapLimit;
@SerializedName("initial_import_size")
public Integer initialImportSize;
@SerializedName("verbose")
public Integer verbose;
@SerializedName("electrum_addr")
public String electrumAddr;
@SerializedName("electrum_skip_merkle")
public Boolean electrumSkipMerkle;
@SerializedName("require_addresses")
public Boolean requireAddresses;
@SerializedName("setup_logger")
public Boolean setupLogger;
@SerializedName("http_addr")
public String httpAddr;
}
public final class ConnectionService extends Service<Void> {
private final Collection<Wallet> wallets;
public ConnectionService() {
this.wallets = null;
}
public ConnectionService(Collection<Wallet> wallets) {
this.wallets = wallets;
}
@Override
protected Task<Void> createTask() {
return new Task<>() {
protected Void call() {
CallbackNotifier notifier = new CallbackNotifier() {
@Override
public void onBooting(long shutdownPtr) {
log.debug("Booting bwt");
Bwt.this.shutdownPtr = shutdownPtr;
if(terminating) {
Bwt.this.shutdown();
terminating = false;
} else {
Platform.runLater(() -> EventManager.get().post(new BwtBootStatusEvent("Connecting to Bitcoin Core node at " + Config.get().getCoreServer() + "...")));
}
}
@Override
public void onSyncProgress(float progress, int tip) {
int percent = (int) (progress * 100.0);
Date tipDate = new Date((long)tip * 1000);
log.debug("Syncing " + percent + "%");
if(!terminating) {
Platform.runLater(() -> EventManager.get().post(new BwtSyncStatusEvent("Syncing" + (percent < 100 ? " (" + percent + "%)" : ""), percent, tipDate)));
}
}
@Override
public void onScanProgress(float progress, int remaining) {
int percent = (int) (progress * 100.0);
Duration remainingDuration = Duration.ofSeconds(remaining);
log.debug("Scanning " + percent + "%");
if(!terminating) {
Platform.runLater(() -> EventManager.get().post(new BwtScanStatusEvent("Scanning" + (percent < 100 ? " (" + percent + "%)" : ""), percent, remainingDuration)));
}
}
@Override
public void onElectrumReady(String addr) {
log.debug("Electrum ready");
if(!terminating) {
Platform.runLater(() -> EventManager.get().post(new BwtElectrumReadyStatusEvent("Electrum server ready", addr)));
}
}
@Override
public void onHttpReady(String addr) {
log.info("http ready at " + addr);
}
@Override
public void onReady() {
log.debug("Bwt ready");
ready = true;
if(!terminating) {
Platform.runLater(() -> EventManager.get().post(new BwtReadyStatusEvent("Server ready")));
}
}
};
if(wallets == null) {
Bwt.this.start(notifier);
} else {
Bwt.this.start(wallets, notifier);
}
return null;
}
};
}
}
public final class DisconnectionService extends Service<Void> {
@Override
protected Task<Void> createTask() {
return new Task<>() {
protected Void call() {
Bwt.this.shutdown();
return null;
}
};
}
}
}
@@ -0,0 +1,5 @@
package com.sparrowwallet.sparrow.net;
public enum CoreAuthType {
COOKIE, USERPASS;
}
@@ -9,11 +9,14 @@ import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.event.ConnectionEvent;
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.TorStatusEvent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.SendController;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.ScheduledService;
@@ -24,6 +27,8 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
public class ElectrumServer {
@@ -39,24 +44,37 @@ public class ElectrumServer {
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
private static String bwtElectrumServer;
private static synchronized Transport getTransport() throws ServerException {
if(transport == null) {
try {
String electrumServer = Config.get().getElectrumServer();
File electrumServerCert = Config.get().getElectrumServerCert();
String proxyServer = Config.get().getProxyServer();
String electrumServer = null;
File electrumServerCert = null;
String proxyServer = null;
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
if(bwtElectrumServer == null) {
throw new ServerConfigException("Could not connect to Bitcoin Core RPC");
}
electrumServer = bwtElectrumServer;
} else if(Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
electrumServer = Config.get().getElectrumServer();
electrumServerCert = Config.get().getElectrumServerCert();
proxyServer = Config.get().getProxyServer();
}
if(electrumServer == null) {
throw new ServerException("Electrum server URL not specified");
throw new ServerConfigException("Electrum server URL not specified");
}
if(electrumServerCert != null && !electrumServerCert.exists()) {
throw new ServerException("Electrum server certificate file not found");
throw new ServerConfigException("Electrum server certificate file not found");
}
Protocol protocol = Protocol.getProtocol(electrumServer);
if(protocol == null) {
throw new ServerException("Electrum server URL must start with " + Protocol.TCP.toUrlString() + " or " + Protocol.SSL.toUrlString());
throw new ServerConfigException("Electrum server URL must start with " + Protocol.TCP.toUrlString() + " or " + Protocol.SSL.toUrlString());
}
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
@@ -76,7 +94,7 @@ public class ElectrumServer {
}
}
} catch (Exception e) {
throw new ServerException(e);
throw new ServerConfigException(e);
}
}
@@ -154,7 +172,7 @@ public class ElectrumServer {
if(!newReferences.isEmpty()) {
//Look for additional nodes to fetch history for by considering the inputs and outputs of new transactions found
log.debug("Found new transactions: " + newReferences);
log.debug(wallet.getName() + " found new transactions: " + newReferences);
Set<WalletNode> additionalNodes = new HashSet<>();
Map<String, WalletNode> walletScriptHashes = getAllScriptHashes(wallet);
for(BlockTransactionHash reference : newReferences) {
@@ -663,6 +681,24 @@ public class ElectrumServer {
}
}
public Set<String> getMempoolScriptHashes(Wallet wallet, Sha256Hash txId, Set<WalletNode> transactionNodes) throws ServerException {
Map<String, String> pathScriptHashes = new LinkedHashMap<>(transactionNodes.size());
for(WalletNode node : transactionNodes) {
pathScriptHashes.put(node.getDerivationPath(), getScriptHash(wallet, node));
}
Set<String> mempoolScriptHashes = new LinkedHashSet<>();
Map<String, ScriptHashTx[]> result = electrumServerRpc.getScriptHashHistory(getTransport(), wallet, pathScriptHashes, true);
for(String path : result.keySet()) {
ScriptHashTx[] txes = result.get(path);
if(Arrays.stream(txes).map(ScriptHashTx::getBlockchainTransactionHash).anyMatch(ref -> txId.equals(ref.getHash()) && ref.getHeight() <= 0)) {
mempoolScriptHashes.add(pathScriptHashes.get(path));
}
}
return mempoolScriptHashes;
}
public static Map<String, WalletNode> getAllScriptHashes(Wallet wallet) {
Map<String, WalletNode> scriptHashes = new HashMap<>();
List<KeyPurpose> purposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
@@ -740,7 +776,11 @@ public class ElectrumServer {
private boolean firstCall = true;
private Thread reader;
private long feeRatesRetrievedAt;
private StringProperty statusProperty = new SimpleStringProperty();
private final Bwt bwt = new Bwt();
private final ReentrantLock bwtStartLock = new ReentrantLock();
private final Condition bwtStartCondition = bwtStartLock.newCondition();
private Throwable bwtStartException;
private final StringProperty statusProperty = new SimpleStringProperty();
public ConnectionService() {
this(true);
@@ -755,6 +795,45 @@ public class ElectrumServer {
return new Task<>() {
protected FeeRatesUpdatedEvent call() throws ServerException {
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();
} finally {
bwtStartLock.unlock();
}
});
Platform.runLater(bwtConnectionService::start);
try {
bwtStartLock.lock();
bwtStartCondition.await();
if(!bwt.isReady()) {
if(bwtStartException != null && bwtStartException.getMessage().contains("Wallet file not specified")) {
throw new ServerException("Bitcoin Core requires Multi-Wallet to be enabled in the Server Preferences");
} else {
throw new ServerException("Check if Bitcoin Core is running, and the authentication details are correct.");
}
}
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
bwtStartLock.unlock();
}
}
}
if(firstCall) {
electrumServer.connect();
@@ -819,16 +898,26 @@ public class ElectrumServer {
public void resetConnection() {
try {
closeActiveConnection();
shutdown();
firstCall = true;
} catch (ServerException e) {
log.error("Error closing connection during connection reset", e);
}
}
public boolean isConnecting() {
return isRunning() && Config.get().getServerType() == ServerType.BITCOIN_CORE && bwt.isRunning() && !bwt.isReady();
}
public boolean isConnected() {
return isRunning() && (Config.get().getServerType() != ServerType.BITCOIN_CORE || (bwt.isRunning() && bwt.isReady()));
}
@Override
public boolean cancel() {
try {
closeActiveConnection();
shutdown();
} catch (ServerException e) {
log.error("Error closing connection", e);
}
@@ -836,6 +925,21 @@ public class ElectrumServer {
return super.cancel();
}
private void shutdown() {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && bwt.isRunning()) {
Bwt.DisconnectionService disconnectionService = bwt.getDisconnectionService();
disconnectionService.setOnSucceeded(workerStateEvent -> {
ElectrumServer.bwtElectrumServer = null;
});
disconnectionService.setOnFailed(workerStateEvent -> {
log.error("Failed to stop BWT", workerStateEvent.getSource().getException());
});
Platform.runLater(disconnectionService::start);
} else {
Platform.runLater(() -> EventManager.get().post(new DisconnectionEvent()));
}
}
@Override
public void reset() {
super.reset();
@@ -852,6 +956,35 @@ public class ElectrumServer {
statusProperty.set(event.getStatus());
}
@Subscribe
public void bwtElectrumReadyStatus(BwtElectrumReadyStatusEvent event) {
if(this.isRunning()) {
ElectrumServer.bwtElectrumServer = Protocol.TCP.toUrlString(HostAndPort.fromString(event.getElectrumAddr()));
}
}
@Subscribe
public void bwtReadyStatus(BwtReadyStatusEvent event) {
if(this.isRunning()) {
try {
bwtStartLock.lock();
bwtStartCondition.signal();
} finally {
bwtStartLock.unlock();
}
}
}
@Subscribe
public void bwtShutdown(BwtShutdownEvent event) {
try {
bwtStartLock.lock();
bwtStartCondition.signal();
} finally {
bwtStartLock.unlock();
}
}
public StringProperty statusProperty() {
return statusProperty;
}
@@ -902,6 +1035,38 @@ public class ElectrumServer {
}
}
public static class TransactionMempoolService extends ScheduledService<Set<String>> {
private final Wallet wallet;
private final Sha256Hash txId;
private final Set<WalletNode> nodes;
private final IntegerProperty iterationCount = new SimpleIntegerProperty(0);
public TransactionMempoolService(Wallet wallet, Sha256Hash txId, Set<WalletNode> nodes) {
this.wallet = wallet;
this.txId = txId;
this.nodes = nodes;
}
public int getIterationCount() {
return iterationCount.get();
}
public IntegerProperty iterationCountProperty() {
return iterationCount;
}
@Override
protected Task<Set<String>> createTask() {
return new Task<>() {
protected Set<String> call() throws ServerException {
iterationCount.set(iterationCount.get() + 1);
ElectrumServer electrumServer = new ElectrumServer();
return electrumServer.getMempoolScriptHashes(wallet, txId, nodes);
}
};
}
}
public static class TransactionReferenceService extends Service<Map<Sha256Hash, BlockTransaction>> {
private final Set<Sha256Hash> references;
private String scriptHash;
@@ -18,7 +18,7 @@ import java.util.LinkedHashMap;
import java.util.Map;
public enum FeeRatesSource {
ELECTRUM_SERVER("Electrum Server") {
ELECTRUM_SERVER("Server") {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
return Collections.emptyMap();
@@ -0,0 +1,119 @@
package com.sparrowwallet.sparrow.net;
import java.io.*;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.ProviderNotFoundException;
import java.nio.file.StandardCopyOption;
/**
* A simple library class which helps with loading dynamic libraries stored in the
* JAR archive. These libraries usually contain implementation of some methods in
* native code (using JNI - Java Native Interface).
*
* @see <a href="http://adamheinrich.com/blog/2012/how-to-load-native-jni-library-from-jar">http://adamheinrich.com/blog/2012/how-to-load-native-jni-library-from-jar</a>
* @see <a href="https://github.com/adamheinrich/native-utils">https://github.com/adamheinrich/native-utils</a>
*
*/
public class NativeUtils {
/**
* The minimum length a prefix for a file has to have according to {@link File#createTempFile(String, String)}}.
*/
private static final int MIN_PREFIX_LENGTH = 3;
public static final String NATIVE_FOLDER_PATH_PREFIX = "nativeutils";
/**
* Temporary directory which will contain the DLLs.
*/
private static File temporaryDir;
/**
* Private constructor - this class will never be instanced
*/
private NativeUtils() {
}
/**
* Loads library from current JAR archive
*
* The file from JAR is copied into system temporary directory and then loaded. The temporary file is deleted after
* exiting.
* Method uses String as filename because the pathname is "abstract", not system-dependent.
*
* @param path The path of file inside JAR as absolute path (beginning with '/'), e.g. /package/File.ext
* @throws IOException If temporary file creation or read/write operation fails
* @throws IllegalArgumentException If source file (param path) does not exist
* @throws IllegalArgumentException If the path is not absolute or if the filename is shorter than three characters
* (restriction of {@link File#createTempFile(java.lang.String, java.lang.String)}).
* @throws FileNotFoundException If the file could not be found inside the JAR.
*/
public static void loadLibraryFromJar(String path) throws IOException {
if (null == path || !path.startsWith("/")) {
throw new IllegalArgumentException("The path has to be absolute (start with '/').");
}
// Obtain filename from path
String[] parts = path.split("/");
String filename = (parts.length > 1) ? parts[parts.length - 1] : null;
// Check if the filename is okay
if (filename == null || filename.length() < MIN_PREFIX_LENGTH) {
throw new IllegalArgumentException("The filename has to be at least 3 characters long.");
}
// Prepare temporary file
if (temporaryDir == null) {
temporaryDir = createTempDirectory(NATIVE_FOLDER_PATH_PREFIX);
temporaryDir.deleteOnExit();
}
File temp = new File(temporaryDir, filename);
try (InputStream is = NativeUtils.class.getResourceAsStream(path)) {
Files.copy(is, temp.toPath(), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
temp.delete();
throw e;
} catch (NullPointerException e) {
temp.delete();
throw new FileNotFoundException("File " + path + " was not found inside JAR.");
}
try {
System.load(temp.getAbsolutePath());
} finally {
if (isPosixCompliant()) {
// Assume POSIX compliant file system, can be deleted after loading
temp.delete();
} else {
// Assume non-POSIX, and don't delete until last file descriptor closed
temp.deleteOnExit();
}
}
}
private static boolean isPosixCompliant() {
try {
return FileSystems.getDefault()
.supportedFileAttributeViews()
.contains("posix");
} catch (FileSystemNotFoundException
| ProviderNotFoundException
| SecurityException e) {
return false;
}
}
private static File createTempDirectory(String prefix) throws IOException {
String tempDir = System.getProperty("java.io.tmpdir");
File generatedDir = new File(tempDir, prefix + System.nanoTime());
if (!generatedDir.mkdir())
throw new IOException("Failed to create temp directory " + generatedDir.getName());
return generatedDir;
}
}
@@ -64,6 +64,27 @@ public enum Protocol {
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
return new ProxyTcpOverTlsTransport(server, serverCert, proxy);
}
},
HTTP {
@Override
public Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
@Override
public Transport getTransport(HostAndPort server, File serverCert) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
@Override
public Transport getTransport(HostAndPort server, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
@Override
public Transport getTransport(HostAndPort server, File serverCert, HostAndPort proxy) throws IOException, CertificateException, NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
throw new UnsupportedOperationException("No transport supported for HTTP");
}
};
public abstract Transport getTransport(HostAndPort server) throws KeyManagementException, NoSuchAlgorithmException;
@@ -105,6 +126,9 @@ public enum Protocol {
if(url.startsWith("ssl://")) {
return SSL;
}
if(url.startsWith("http://")) {
return HTTP;
}
return null;
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.net;
public class ServerConfigException extends ServerException {
public ServerConfigException() {
}
public ServerConfigException(String message) {
super(message);
}
public ServerConfigException(Throwable cause) {
super(cause);
}
public ServerConfigException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.net;
public enum ServerType {
BITCOIN_CORE("Bitcoin Core"), ELECTRUM_SERVER("Electrum Server");
private final String name;
ServerType(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
@@ -75,7 +75,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, ScriptHashTx[]> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Loading transactions for " + path));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Loading transactions for " + path));
try {
ScriptHashTx[] scriptHashTxes = new RetryLogic<ScriptHashTx[]>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(ScriptHashTx[].class).method("blockchain.scripthash.get_history").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).execute());
@@ -120,7 +120,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, String> result = new LinkedHashMap<>();
for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Finding transactions for " + path));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path));
try {
String scriptHash = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(path + "-" + idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
@@ -140,7 +140,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<Integer, String> result = new LinkedHashMap<>();
for(Integer blockHeight : blockHeights) {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving block at height " + blockHeight));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving block at height " + blockHeight));
try {
String blockHeader = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.block.header").id(idCounter.incrementAndGet()).params(blockHeight).execute());
@@ -161,7 +161,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, String> result = new LinkedHashMap<>();
for(String txid : txids) {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false, "Retrieving transaction [" + txid.substring(0, 6) + "]"));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Retrieving transaction [" + txid.substring(0, 6) + "]"));
try {
String rawTxHex = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid).execute());
@@ -181,7 +181,8 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
Map<String, VerboseTransaction> result = new LinkedHashMap<>();
for(String txid : txids) {
try {
VerboseTransaction verboseTransaction = new RetryLogic<VerboseTransaction>(MAX_RETRIES, RETRY_DELAY, IllegalStateException.class).getResult(() ->
//The server may return an error if the transaction has not yet been broadcasted - this is a valid state so only try once
VerboseTransaction verboseTransaction = new RetryLogic<VerboseTransaction>(1, RETRY_DELAY, IllegalStateException.class).getResult(() ->
client.createRequest().returnAs(VerboseTransaction.class).method("blockchain.transaction.get").id(idCounter.incrementAndGet()).params(txid, true).execute());
result.put(txid, verboseTransaction);
} catch(Exception e) {
@@ -213,7 +214,7 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
verboseTransaction.blockhash = Sha256Hash.ZERO_HASH.toString();
result.put(txid, verboseTransaction);
} catch(Exception ex) {
throw new ElectrumServerRpcException("Error retrieving transaction: ", ex);
//ignore
}
}
}
@@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory;
import javax.net.SocketFactory;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
@@ -130,9 +131,11 @@ public class TcpTransport implements Transport, Closeable {
Thread.currentThread().interrupt();
}
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
while(running) {
try {
String received = readInputStream();
String received = readInputStream(in);
if(received.contains("method") && !received.contains("error")) {
//Handle subscription notification
jsonRpcServer.handle(received, subscriptionService);
@@ -147,7 +150,7 @@ public class TcpTransport implements Transport, Closeable {
//Restore interrupt status and continue
Thread.currentThread().interrupt();
} catch(Exception e) {
log.debug("Connection error while reading", e);
log.trace("Connection error while reading", e);
if(running) {
lastException = e;
reading = false;
@@ -157,17 +160,25 @@ public class TcpTransport implements Transport, Closeable {
}
}
}
} catch(IOException e) {
log.error("Error opening socket inputstream", e);
if(running) {
lastException = e;
reading = false;
readingCondition.signal();
//Allow this thread to terminate as we will need to reconnect with a new transport anyway
running = false;
}
} finally {
readLock.unlock();
}
}
protected String readInputStream() throws IOException {
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
protected String readInputStream(BufferedReader in) throws IOException {
String response = in.readLine();
if(response == null) {
throw new IOException("Could not connect to server at " + Config.get().getElectrumServer());
throw new IOException("Could not connect to server at " + Config.get().getServerAddress());
}
return response;
@@ -16,6 +16,7 @@ import javax.net.ssl.HttpsURLConnection;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.SignatureException;
import java.util.Map;
@@ -45,7 +46,7 @@ public class VersionCheckService extends ScheduledService<VersionUpdatedEvent> {
URL url = new URL(VERSION_CHECK_URL);
HttpsURLConnection conn = (HttpsURLConnection)url.openConnection();
try(InputStreamReader reader = new InputStreamReader(conn.getInputStream())) {
try(InputStreamReader reader = new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)) {
Gson gson = new Gson();
return gson.fromJson(reader, VersionCheck.class);
}
@@ -58,6 +58,8 @@ public class GeneralPreferencesController extends PreferencesDetailController {
public void initializeView(Config config) {
if(config.getBitcoinUnit() != null) {
bitcoinUnit.setValue(config.getBitcoinUnit());
} else {
bitcoinUnit.setValue(BitcoinUnit.AUTO);
}
bitcoinUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
@@ -3,6 +3,8 @@ package com.sparrowwallet.sparrow.preferences;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
@@ -24,6 +26,10 @@ public class PreferencesController implements Initializable {
@FXML
private StackPane preferencesPane;
private final BooleanProperty closing = new SimpleBooleanProperty(false);
private final BooleanProperty reconnectOnClosing = new SimpleBooleanProperty(false);
@Override
public void initialize(URL location, ResourceBundle resources) {
@@ -56,6 +62,18 @@ public class PreferencesController implements Initializable {
}
}
BooleanProperty closingProperty() {
return closing;
}
public boolean isReconnectOnClosing() {
return reconnectOnClosing.get();
}
public BooleanProperty reconnectOnClosingProperty() {
return reconnectOnClosing;
}
FXMLLoader setPreferencePane(String fxmlName) {
preferencesPane.getChildren().removeAll(preferencesPane.getChildren());
@@ -15,8 +15,6 @@ import org.controlsfx.tools.Borders;
import java.io.IOException;
public class PreferencesDialog extends Dialog<Boolean> {
private final boolean existingConnection;
public PreferencesDialog() {
this(null);
}
@@ -49,11 +47,12 @@ public class PreferencesDialog extends Dialog<Boolean> {
}
dialogPane.setPrefWidth(650);
dialogPane.setPrefHeight(550);
dialogPane.setPrefHeight(600);
existingConnection = ElectrumServer.isConnected();
preferencesController.reconnectOnClosingProperty().set(AppServices.isConnecting() || AppServices.isConnected());
setOnCloseRequest(event -> {
if(existingConnection && !ElectrumServer.isConnected()) {
preferencesController.closingProperty().set(true);
if(preferencesController.isReconnectOnClosing() && !(AppServices.isConnecting() || AppServices.isConnected())) {
EventManager.get().post(new RequestConnectEvent());
}
});
@@ -1,24 +1,24 @@
package com.sparrowwallet.sparrow.preferences;
import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Mode;
import com.sparrowwallet.sparrow.control.TextFieldValidator;
import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch;
import com.sparrowwallet.sparrow.event.ConnectionEvent;
import com.sparrowwallet.sparrow.event.RequestDisconnectEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.Protocol;
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.Button;
import javafx.scene.control.Control;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import javafx.scene.control.*;
import javafx.scene.text.Font;
import javafx.stage.DirectoryChooser;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
@@ -30,30 +30,76 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import tornadofx.control.Form;
import javax.net.ssl.SSLHandshakeException;
import java.io.File;
import java.io.FileInputStream;
import java.security.cert.CertificateFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.List;
public class ServerPreferencesController extends PreferencesDetailController {
private static final Logger log = LoggerFactory.getLogger(ServerPreferencesController.class);
@FXML
private TextField host;
private ToggleGroup serverTypeToggleGroup;
@FXML
private TextField port;
private Form coreForm;
@FXML
private UnlabeledToggleSwitch useSsl;
private TextField coreHost;
@FXML
private TextField certificate;
private TextField corePort;
@FXML
private Button certificateSelect;
private ToggleGroup coreAuthToggleGroup;
@FXML
private Field coreDataDirField;
@FXML
private TextField coreDataDir;
@FXML
private Button coreDataDirSelect;
@FXML
private Field coreUserPassField;
@FXML
private TextField coreUser;
@FXML
private PasswordField corePass;
@FXML
private UnlabeledToggleSwitch coreMultiWallet;
@FXML
private TextField coreWallet;
@FXML
private Form electrumForm;
@FXML
private TextField electrumHost;
@FXML
private TextField electrumPort;
@FXML
private UnlabeledToggleSwitch electrumUseSsl;
@FXML
private TextField electrumCertificate;
@FXML
private Button electrumCertificateSelect;
@FXML
private UnlabeledToggleSwitch useProxy;
@@ -75,35 +121,106 @@ public class ServerPreferencesController extends PreferencesDetailController {
private final ValidationSupport validationSupport = new ValidationSupport();
private ElectrumServer.ConnectionService connectionService;
@Override
public void initializeView(Config config) {
EventManager.get().register(this);
getMasterController().closingProperty().addListener((observable, oldValue, newValue) -> {
EventManager.get().unregister(this);
});
Platform.runLater(this::setupValidation);
port.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_INTEGERS, 5).getFormatter());
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();
electrumForm.setVisible(serverType == ServerType.ELECTRUM_SERVER);
config.setServerType(serverType);
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE, ""));
testResults.clear();
if(existingType != serverType) {
EventManager.get().post(new ServerTypeChangedEvent(serverType));
}
} else if(oldValue != null) {
oldValue.setSelected(true);
}
});
ServerType serverType = config.getServerType() != null ? config.getServerType() : (config.getCoreServer() == null && config.getElectrumServer() != null ? ServerType.ELECTRUM_SERVER : ServerType.BITCOIN_CORE);
serverTypeToggleGroup.selectToggle(serverTypeToggleGroup.getToggles().stream().filter(toggle -> toggle.getUserData() == serverType).findFirst().orElse(null));
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());
host.textProperty().addListener(getElectrumServerListener(config));
port.textProperty().addListener(getElectrumServerListener(config));
coreHost.textProperty().addListener(getBitcoinCoreListener(config));
corePort.textProperty().addListener(getBitcoinCoreListener(config));
coreUser.textProperty().addListener(getBitcoinAuthListener(config));
corePass.textProperty().addListener(getBitcoinAuthListener(config));
coreWallet.textProperty().addListener(getBitcoinWalletListener(config));
electrumHost.textProperty().addListener(getElectrumServerListener(config));
electrumPort.textProperty().addListener(getElectrumServerListener(config));
proxyHost.textProperty().addListener(getProxyListener(config));
proxyPort.textProperty().addListener(getProxyListener(config));
useSsl.selectedProperty().addListener((observable, oldValue, newValue) -> {
setElectrumServerInConfig(config);
certificate.setDisable(!newValue);
certificateSelect.setDisable(!newValue);
coreDataDirField.managedProperty().bind(coreDataDirField.visibleProperty());
coreUserPassField.managedProperty().bind(coreUserPassField.visibleProperty());
coreUserPassField.visibleProperty().bind(coreDataDirField.visibleProperty().not());
coreAuthToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
if(coreAuthToggleGroup.getSelectedToggle() != null) {
CoreAuthType coreAuthType = (CoreAuthType)newValue.getUserData();
coreDataDirField.setVisible(coreAuthType == CoreAuthType.COOKIE);
config.setCoreAuthType(coreAuthType);
} else if(oldValue != null) {
oldValue.setSelected(true);
}
});
CoreAuthType coreAuthType = config.getCoreAuthType() != null ? config.getCoreAuthType() : CoreAuthType.COOKIE;
coreAuthToggleGroup.selectToggle(coreAuthToggleGroup.getToggles().stream().filter(toggle -> toggle.getUserData() == coreAuthType).findFirst().orElse(null));
coreDataDir.textProperty().addListener((observable, oldValue, newValue) -> {
File dataDir = getDirectory(newValue);
config.setCoreDataDir(dataDir);
});
certificate.textProperty().addListener((observable, oldValue, newValue) -> {
File crtFile = getCertificate(newValue);
if(crtFile != null) {
config.setElectrumServerCert(crtFile);
} else {
config.setElectrumServerCert(null);
coreDataDirSelect.setOnAction(event -> {
Stage window = new Stage();
DirectoryChooser directorChooser = new DirectoryChooser();
directorChooser.setTitle("Select Bitcoin Core Data Directory");
directorChooser.setInitialDirectory(config.getCoreDataDir() != null ? config.getCoreDataDir() : new File(System.getProperty("user.home")));
File dataDir = directorChooser.showDialog(window);
if(dataDir != null) {
coreDataDir.setText(dataDir.getAbsolutePath());
}
});
certificateSelect.setOnAction(event -> {
coreMultiWallet.selectedProperty().addListener((observable, oldValue, newValue) -> {
config.setCoreMultiWallet(newValue);
coreWallet.setDisable(!newValue);
});
electrumUseSsl.selectedProperty().addListener((observable, oldValue, newValue) -> {
setElectrumServerInConfig(config);
electrumCertificate.setDisable(!newValue);
electrumCertificateSelect.setDisable(!newValue);
});
electrumCertificate.textProperty().addListener((observable, oldValue, newValue) -> {
File crtFile = getCertificate(newValue);
config.setElectrumServerCert(crtFile);
});
electrumCertificateSelect.setOnAction(event -> {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
@@ -115,7 +232,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
File file = fileChooser.showOpenDialog(window);
if(file != null) {
certificate.setText(file.getAbsolutePath());
electrumCertificate.setText(file.getAbsolutePath());
}
});
@@ -127,45 +244,31 @@ public class ServerPreferencesController extends PreferencesDetailController {
proxyPort.setDisable(!newValue);
if(newValue) {
useSsl.setSelected(true);
useSsl.setDisable(true);
electrumUseSsl.setSelected(true);
electrumUseSsl.setDisable(true);
} else {
useSsl.setDisable(false);
electrumUseSsl.setDisable(false);
}
});
boolean isConnected = ElectrumServer.isConnected();
setFieldsEditable(!isConnected);
boolean isConnected = AppServices.isConnecting() || AppServices.isConnected();
if(AppServices.isConnecting()) {
testResults.appendText("Connecting to server, please wait...");
}
testConnection.managedProperty().bind(testConnection.visibleProperty());
testConnection.setVisible(!isConnected);
setTestResultsFont();
testConnection.setOnAction(event -> {
testResults.setText("Connecting to " + config.getElectrumServer() + "...");
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.ELLIPSIS_H, null));
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService(false);
connectionService.setPeriod(Duration.ZERO);
EventManager.get().register(connectionService);
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
testResults.setText(testResults.getText() + "\n" + newValue);
});
connectionService.setOnSucceeded(successEvent -> {
EventManager.get().unregister(connectionService);
ConnectionEvent connectionEvent = (ConnectionEvent)connectionService.getValue();
showConnectionSuccess(connectionEvent.getServerVersion(), connectionEvent.getServerBanner());
connectionService.cancel();
});
connectionService.setOnFailed(workerStateEvent -> {
EventManager.get().unregister(connectionService);
showConnectionFailure(workerStateEvent);
connectionService.cancel();
});
connectionService.start();
testResults.setText("Connecting to " + config.getServerAddress() + "...");
startElectrumConnection();
});
editConnection.managedProperty().bind(editConnection.visibleProperty());
editConnection.setVisible(isConnected);
editConnection.setDisable(AppServices.isConnecting());
editConnection.setOnAction(event -> {
EventManager.get().post(new RequestDisconnectEvent());
setFieldsEditable(true);
@@ -173,27 +276,64 @@ public class ServerPreferencesController extends PreferencesDetailController {
testConnection.setVisible(true);
});
String coreServer = config.getCoreServer();
if(coreServer != null) {
Protocol protocol = Protocol.getProtocol(coreServer);
if(protocol != null) {
HostAndPort server = protocol.getServerHostAndPort(coreServer);
coreHost.setText(server.getHost());
if(server.hasPort()) {
corePort.setText(Integer.toString(server.getPort()));
}
}
} else {
coreHost.setText("127.0.0.1");
corePort.setText(String.valueOf(Network.get().getDefaultPort()));
}
coreDataDir.setText(config.getCoreDataDir() != null ? config.getCoreDataDir().getAbsolutePath() : getDefaultCoreDataDir().getAbsolutePath());
if(config.getCoreAuth() != null) {
String[] userPass = config.getCoreAuth().split(":");
if(userPass.length > 0) {
coreUser.setText(userPass[0]);
}
if(userPass.length > 1) {
corePass.setText(userPass[1]);
}
}
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);
if(protocol != null) {
boolean ssl = protocol.equals(Protocol.SSL);
useSsl.setSelected(ssl);
certificate.setDisable(!ssl);
certificateSelect.setDisable(!ssl);
electrumUseSsl.setSelected(ssl);
electrumCertificate.setDisable(!ssl);
electrumCertificateSelect.setDisable(!ssl);
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
host.setText(server.getHost());
electrumHost.setText(server.getHost());
if(server.hasPort()) {
port.setText(Integer.toString(server.getPort()));
electrumPort.setText(Integer.toString(server.getPort()));
}
}
}
File certificateFile = config.getElectrumServerCert();
if(certificateFile != null) {
certificate.setText(certificateFile.getAbsolutePath());
electrumCertificate.setText(certificateFile.getAbsolutePath());
}
useProxy.setSelected(config.isUseProxy());
@@ -201,8 +341,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
proxyPort.setDisable(!config.isUseProxy());
if(config.isUseProxy()) {
useSsl.setSelected(true);
useSsl.setDisable(true);
electrumUseSsl.setSelected(true);
electrumUseSsl.setDisable(true);
}
String proxyServer = config.getProxyServer();
@@ -213,17 +353,59 @@ public class ServerPreferencesController extends PreferencesDetailController {
proxyPort.setText(Integer.toString(server.getPort()));
}
}
setFieldsEditable(!isConnected);
}
private void startElectrumConnection() {
if(connectionService != null && connectionService.isRunning()) {
connectionService.cancel();
}
connectionService = new ElectrumServer.ConnectionService(false);
connectionService.setPeriod(Duration.hours(1));
EventManager.get().register(connectionService);
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> {
testResults.setText(testResults.getText() + "\n" + newValue);
});
connectionService.setOnSucceeded(successEvent -> {
EventManager.get().unregister(connectionService);
ConnectionEvent connectionEvent = (ConnectionEvent)connectionService.getValue();
showConnectionSuccess(connectionEvent.getServerVersion(), connectionEvent.getServerBanner());
getMasterController().reconnectOnClosingProperty().set(true);
Config.get().setMode(Mode.ONLINE);
connectionService.cancel();
});
connectionService.setOnFailed(workerStateEvent -> {
EventManager.get().unregister(connectionService);
showConnectionFailure(workerStateEvent);
connectionService.cancel();
});
connectionService.start();
}
private void setFieldsEditable(boolean editable) {
host.setEditable(editable);
port.setEditable(editable);
useSsl.setDisable(!editable);
certificate.setEditable(editable);
certificateSelect.setDisable(!editable);
serverTypeToggleGroup.getToggles().forEach(toggle -> ((ToggleButton)toggle).setDisable(!editable));
coreHost.setDisable(!editable);
corePort.setDisable(!editable);
coreAuthToggleGroup.getToggles().forEach(toggle -> ((ToggleButton)toggle).setDisable(!editable));
coreDataDir.setDisable(!editable);
coreDataDirSelect.setDisable(!editable);
coreUser.setDisable(!editable);
corePass.setDisable(!editable);
coreMultiWallet.setDisable(!editable);
coreWallet.setDisable(!editable);
electrumHost.setDisable(!editable);
electrumPort.setDisable(!editable);
electrumUseSsl.setDisable(!editable);
electrumCertificate.setDisable(!editable);
electrumCertificateSelect.setDisable(!editable);
useProxy.setDisable(!editable);
proxyHost.setEditable(editable);
proxyPort.setEditable(editable);
proxyHost.setDisable(!editable);
proxyPort.setDisable(!editable);
}
private void showConnectionSuccess(List<String> serverVersion, String serverBanner) {
@@ -252,12 +434,32 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
private void setupValidation() {
validationSupport.registerValidator(host, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid host name", getHost(newValue) == null)
validationSupport.registerValidator(coreHost, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Core host", getHost(newValue) == null)
));
validationSupport.registerValidator(port, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
validationSupport.registerValidator(corePort, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Core port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
));
validationSupport.registerValidator(coreDataDir, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Core Data Dir required", coreAuthToggleGroup.getSelectedToggle().getUserData() == CoreAuthType.COOKIE && (newValue.isEmpty() || getDirectory(newValue) == null))
));
validationSupport.registerValidator(coreUser, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Core user required", coreAuthToggleGroup.getSelectedToggle().getUserData() == CoreAuthType.USERPASS && newValue.isEmpty())
));
validationSupport.registerValidator(corePass, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Core pass required", coreAuthToggleGroup.getSelectedToggle().getUserData() == CoreAuthType.USERPASS && newValue.isEmpty())
));
validationSupport.registerValidator(electrumHost, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Electrum host", getHost(newValue) == null)
));
validationSupport.registerValidator(electrumPort, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Electrum port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
));
validationSupport.registerValidator(proxyHost, Validator.combine(
@@ -269,13 +471,44 @@ public class ServerPreferencesController extends PreferencesDetailController {
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid proxy port", !newValue.isEmpty() && !isValidPort(Integer.parseInt(newValue)))
));
validationSupport.registerValidator(certificate, Validator.combine(
validationSupport.registerValidator(electrumCertificate, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid certificate file", newValue != null && !newValue.isEmpty() && getCertificate(newValue) == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
}
@NotNull
private ChangeListener<String> getBitcoinCoreListener(Config config) {
return (observable, oldValue, newValue) -> {
setCoreServerInConfig(config);
};
}
private void setCoreServerInConfig(Config config) {
String hostAsString = getHost(coreHost.getText());
Integer portAsInteger = getPort(corePort.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
config.setCoreServer(Protocol.HTTP.toUrlString(hostAsString, portAsInteger));
} else if(hostAsString != null) {
config.setCoreServer(Protocol.HTTP.toUrlString(hostAsString));
}
}
@NotNull
private ChangeListener<String> getBitcoinAuthListener(Config config) {
return (observable, oldValue, newValue) -> {
config.setCoreAuth(coreUser.getText() + ":" + corePass.getText());
};
}
@NotNull
private ChangeListener<String> getBitcoinWalletListener(Config config) {
return (observable, oldValue, newValue) -> {
config.setCoreWallet(coreWallet.getText());
};
}
@NotNull
private ChangeListener<String> getElectrumServerListener(Config config) {
return (observable, oldValue, newValue) -> {
@@ -284,8 +517,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
private void setElectrumServerInConfig(Config config) {
String hostAsString = getHost(host.getText());
Integer portAsInteger = getPort(port.getText());
String hostAsString = getHost(electrumHost.getText());
Integer portAsInteger = getPort(electrumPort.getText());
if(hostAsString != null && portAsInteger != null && isValidPort(portAsInteger)) {
config.setElectrumServer(getProtocol().toUrlString(hostAsString, portAsInteger));
} else if(hostAsString != null) {
@@ -311,7 +544,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
private Protocol getProtocol() {
return (useSsl.isSelected() ? Protocol.SSL : Protocol.TCP);
return (electrumUseSsl.isSelected() ? Protocol.SSL : Protocol.TCP);
}
private String getHost(String text) {
@@ -330,6 +563,19 @@ public class ServerPreferencesController extends PreferencesDetailController {
}
}
private File getDirectory(String dirLocation) {
try {
File dirFile = new File(dirLocation);
if(!dirFile.exists() || !dirFile.isDirectory()) {
return null;
}
return dirFile;
} catch (Exception e) {
return null;
}
}
private File getCertificate(String crtFileLocation) {
try {
File crtFile = new File(crtFileLocation);
@@ -357,4 +603,48 @@ public class ServerPreferencesController extends PreferencesDetailController {
private static boolean isValidPort(int port) {
return port >= 0 && port <= 65535;
}
private File getDefaultCoreDataDir() {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
return new File(System.getProperty("user.home") + "/Library/Application Support/Bitcoin");
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
return new File(System.getenv("APPDATA") + "/Bitcoin");
} else {
return new File(System.getProperty("user.home") + "/.bitcoin");
}
}
private void setTestResultsFont() {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
testResults.setFont(Font.font("Monaco", 11));
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
testResults.setFont(Font.font("Lucida Console", 11));
} else {
testResults.setFont(Font.font("monospace", 11));
}
}
@Subscribe
public void bwtStatus(BwtStatusEvent event) {
if(!(event instanceof BwtSyncStatusEvent)) {
testResults.appendText("\n" + event.getStatus());
}
if(event instanceof BwtReadyStatusEvent) {
editConnection.setDisable(false);
}
}
@Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) {
editConnection.setDisable(false);
if(connectionService != null && connectionService.isRunning() && event.getProgress() < 100) {
DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm");
testResults.appendText("\nThe connection to the Bitcoin Core node was successful, but it is still syncing and cannot be used yet.");
testResults.appendText("\nCurrently " + event.getProgress() + "% completed to date " + dateFormat.format(event.getTip()));
testConnection.setGraphic(getGlyph(FontAwesome5.Glyph.QUESTION_CIRCLE, null));
connectionService.cancel();
}
}
}
@@ -25,6 +25,7 @@ import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
@@ -36,6 +37,7 @@ import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -202,6 +204,8 @@ public class HeadersController extends TransactionFormController implements Init
@FXML
private Button payjoinButton;
private ElectrumServer.TransactionMempoolService transactionMempoolService;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -412,17 +416,17 @@ public class HeadersController extends TransactionFormController implements Init
}
});
headersForm.signingWalletProperty().addListener((observable, oldValue, signingWallet) -> {
initializeSignButton(signingWallet);
updateSignedKeystores(signingWallet);
int threshold = signingWallet.getDefaultPolicy().getNumSignaturesRequired();
signaturesProgressBar.initialize(headersForm.getSignatureKeystoreMap(), threshold);
});
Platform.runLater(this::requestOpenWallets);
}
headersForm.signingWalletProperty().addListener((observable, oldValue, signingWallet) -> {
initializeSignButton(signingWallet);
updateSignedKeystores(signingWallet);
int threshold = signingWallet.getDefaultPolicy().getNumSignaturesRequired();
signaturesProgressBar.initialize(headersForm.getSignatureKeystoreMap(), threshold);
});
blockchainForm.setDynamicUpdate(this);
}
@@ -753,7 +757,7 @@ public class HeadersController extends TransactionFormController implements Init
}
private void updateSignedKeystores(Wallet signingWallet) {
Map<PSBTInput, Map<TransactionSignature, Keystore>> signedKeystoresMap = signingWallet.getSignedKeystores(headersForm.getPsbt());
Map<?, Map<TransactionSignature, Keystore>> signedKeystoresMap = headersForm.getPsbt() == null ? signingWallet.getSignedKeystores(headersForm.getTransaction()) : signingWallet.getSignedKeystores(headersForm.getPsbt());
Optional<Map<TransactionSignature, Keystore>> optSignedKeystores = signedKeystoresMap.values().stream().filter(map -> !map.isEmpty()).min(Comparator.comparingInt(Map::size));
optSignedKeystores.ifPresent(signedKeystores -> {
headersForm.getSignatureKeystoreMap().keySet().retainAll(signedKeystores.keySet());
@@ -778,7 +782,9 @@ public class HeadersController extends TransactionFormController implements Init
public void broadcastTransaction(ActionEvent event) {
broadcastButton.setDisable(true);
extractTransaction(event);
if(headersForm.getPsbt() != null) {
extractTransaction(event);
}
if(headersForm.getSigningWallet() instanceof FinalizingPSBTWallet) {
//Ensure the script hashes of the UTXOs in FinalizingPSBTWallet are subscribed to
@@ -791,7 +797,46 @@ public class HeadersController extends TransactionFormController implements Init
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(headersForm.getTransaction());
broadcastTransactionService.setOnSucceeded(workerStateEvent -> {
//Do nothing and wait for WalletNodeHistoryChangedEvent to indicate tx is in mempool
//Although we wait for WalletNodeHistoryChangedEvent to indicate tx is in mempool, start a scheduled service to check the script hashes should notifications fail
if(headersForm.getSigningWallet() != null) {
if(transactionMempoolService != null) {
transactionMempoolService.cancel();
}
transactionMempoolService = new ElectrumServer.TransactionMempoolService(headersForm.getSigningWallet(), headersForm.getTransaction().getTxId(), headersForm.getSigningWalletNodes());
transactionMempoolService.setDelay(Duration.seconds(3));
transactionMempoolService.setPeriod(Duration.seconds(10));
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
Set<String> scriptHashes = transactionMempoolService.getValue();
if(!scriptHashes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHashes.iterator().next())));
}
if(transactionMempoolService.getIterationCount() > 3) {
transactionMempoolService.cancel();
broadcastProgressBar.setProgress(0);
log.error("Timeout searching for broadcasted transaction");
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again.");
broadcastButton.setDisable(false);
}
});
transactionMempoolService.start();
} else {
Sha256Hash txid = headersForm.getTransaction().getTxId();
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid));
transactionReferenceService.setOnSucceeded(successEvent -> {
Map<Sha256Hash, BlockTransaction> transactionMap = transactionReferenceService.getValue();
BlockTransaction blockTransaction = transactionMap.get(txid);
if(blockTransaction != null) {
headersForm.setBlockTransaction(blockTransaction);
updateBlockchainForm(blockTransaction, AppServices.getCurrentBlockHeight());
}
});
transactionReferenceService.setOnFailed(failedEvent -> {
log.error("Error fetching broadcasted transaction", failedEvent.getSource().getException());
});
transactionReferenceService.start();
}
});
broadcastTransactionService.setOnFailed(workerStateEvent -> {
broadcastProgressBar.setProgress(0);
@@ -876,6 +921,43 @@ public class HeadersController extends TransactionFormController implements Init
if(event.getTxId().equals(headersForm.getTransaction().getTxId())) {
if(event.getBlockTransaction() != null && (!Sha256Hash.ZERO_HASH.equals(event.getBlockTransaction().getBlockHash()) || headersForm.getBlockTransaction() == null)) {
updateBlockchainForm(event.getBlockTransaction(), AppServices.getCurrentBlockHeight());
} else if(headersForm.getPsbt() == null) {
boolean isSigned = true;
ObservableMap<TransactionSignature, Keystore> signatureKeystoreMap = FXCollections.observableMap(new LinkedHashMap<>());
for(TransactionInput txInput : headersForm.getTransaction().getInputs()) {
List<TransactionSignature> signatures = txInput.hasWitness() ? txInput.getWitness().getSignatures() : txInput.getScriptSig().getSignatures();
if(signatures.isEmpty()) {
isSigned = false;
break;
}
if(signatureKeystoreMap.isEmpty()) {
for(int i = 0; i < signatures.size(); i++) {
signatureKeystoreMap.put(signatures.get(i), new Keystore("Keystore " + (i+1)));
}
}
}
if(isSigned) {
blockchainForm.setVisible(false);
signaturesForm.setVisible(true);
broadcastButtonBox.setVisible(true);
viewFinalButton.setDisable(true);
if(headersForm.getSigningWallet() == null) {
for(Wallet wallet : AppServices.get().getOpenWallets().keySet()) {
if(wallet.canSign(headersForm.getTransaction())) {
headersForm.setSigningWallet(wallet);
break;
}
}
}
if(headersForm.getSigningWallet() == null) {
signaturesProgressBar.initialize(signatureKeystoreMap, signatureKeystoreMap.size());
}
}
}
Long feeAmt = calculateFee(event.getInputTransactions());
@@ -1022,6 +1104,10 @@ public class HeadersController extends TransactionFormController implements Init
@Subscribe
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
if(headersForm.getSigningWallet() != null && event.getWalletNode(headersForm.getSigningWallet()) != null && headersForm.isTransactionFinalized()) {
if(transactionMempoolService != null) {
transactionMempoolService.cancel();
}
Sha256Hash txid = headersForm.getTransaction().getTxId();
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(Set.of(txid), event.getScriptHash());
transactionReferenceService.setOnSucceeded(successEvent -> {
@@ -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() {
@@ -326,7 +333,7 @@ public class TransactionController implements Initializable {
}
private void fetchThisAndInputBlockTransactions(int indexStart, int indexEnd) {
if(AppServices.isOnline() && indexStart < getTransaction().getInputs().size()) {
if(AppServices.isConnected() && indexStart < getTransaction().getInputs().size()) {
Set<Sha256Hash> references = new HashSet<>();
if(getPSBT() == null) {
references.add(getTransaction().getTxId());
@@ -378,7 +385,7 @@ public class TransactionController implements Initializable {
}
private void fetchOutputBlockTransactions(int indexStart, int indexEnd) {
if(AppServices.isOnline() && getPSBT() == null && indexStart < getTransaction().getOutputs().size()) {
if(AppServices.isConnected() && getPSBT() == null && indexStart < getTransaction().getOutputs().size()) {
int maxIndex = Math.min(getTransaction().getOutputs().size(), indexEnd);
ElectrumServer.TransactionOutputsReferenceService transactionOutputsReferenceService = new ElectrumServer.TransactionOutputsReferenceService(getTransaction(), indexStart, maxIndex);
transactionOutputsReferenceService.setOnSucceeded(successEvent -> {
@@ -1,21 +1,18 @@
package com.sparrowwallet.sparrow.transaction;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionSignature;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableMap;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
public class TransactionData {
private Transaction transaction;
@@ -156,4 +153,30 @@ public class TransactionData {
public Collection<Keystore> getSignedKeystores() {
return signatureKeystoreMap.values();
}
public Set<WalletNode> getSigningWalletNodes() {
if(getSigningWallet() == null) {
throw new IllegalStateException("Signing wallet cannot be null");
}
Set<WalletNode> signingWalletNodes = new LinkedHashSet<>();
for(TransactionInput txInput : transaction.getInputs()) {
Optional<WalletNode> optNode = getSigningWallet().getWalletTxos().entrySet().stream().filter(entry -> entry.getKey().getHash().equals(txInput.getOutpoint().getHash()) && entry.getKey().getIndex() == txInput.getOutpoint().getIndex()).map(Map.Entry::getValue).findFirst();
optNode.ifPresent(signingWalletNodes::add);
}
for(TransactionOutput txOutput : transaction.getOutputs()) {
WalletNode changeNode = getSigningWallet().getWalletOutputScripts(KeyPurpose.CHANGE).get(txOutput.getScript());
if(changeNode != null) {
signingWalletNodes.add(changeNode);
} else {
WalletNode receiveNode = getSigningWallet().getWalletOutputScripts(KeyPurpose.RECEIVE).get(txOutput.getScript());
if(receiveNode != null) {
signingWalletNodes.add(receiveNode);
}
}
}
return signingWalletNodes;
}
}
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ObservableMap;
@@ -16,6 +17,7 @@ import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
public abstract class TransactionForm {
protected final TransactionData txdata;
@@ -92,6 +94,10 @@ public abstract class TransactionForm {
return txdata.getSignedKeystores();
}
public Set<WalletNode> getSigningWalletNodes() {
return txdata.getSigningWalletNodes();
}
public boolean isEditable() {
if(getBlockTransaction() != null) {
return false;
@@ -2,16 +2,23 @@ package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.DateStringConverter;
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import java.net.URL;
import java.time.ZoneId;
import java.util.Date;
import java.util.ResourceBundle;
public class AdvancedController implements Initializable {
@FXML
private DatePicker birthDate;
@FXML
private Spinner<Integer> gapLimit;
@@ -21,6 +28,17 @@ public class AdvancedController implements Initializable {
}
public void initializeView(Wallet wallet) {
birthDate.setConverter(new DateStringConverter());
if(wallet.getBirthDate() != null) {
birthDate.setValue(wallet.getBirthDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
}
birthDate.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
wallet.setBirthDate(Date.from(newValue.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()));
EventManager.get().post(new SettingsChangedEvent(wallet, SettingsChangedEvent.Type.BIRTH_DATE));
}
});
gapLimit.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(Wallet.DEFAULT_LOOKAHEAD, 10000, wallet.getGapLimit()));
gapLimit.valueProperty().addListener((observable, oldValue, newValue) -> {
wallet.setGapLimit(newValue);
@@ -138,7 +138,7 @@ public class ReceiveController extends WalletFormController implements Initializ
private void updateLastUsed() {
Set<BlockTransactionHashIndex> currentOutputs = currentEntry.getNode().getTransactionOutputs();
if(AppServices.isOnline() && currentOutputs.isEmpty()) {
if(AppServices.isConnected() && currentOutputs.isEmpty()) {
lastUsed.setText("Never");
lastUsed.setGraphic(getUnusedGlyph());
} else if(!currentOutputs.isEmpty()) {
@@ -337,6 +337,15 @@ 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());
});
}
});
}
private void initializeTabHeader(int count) {
@@ -14,10 +14,7 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Storage;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
@@ -28,17 +25,14 @@ import javafx.fxml.Initializable;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import org.controlsfx.control.RangeSlider;
import org.controlsfx.tools.Borders;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Fieldset;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.*;
import java.util.stream.Collectors;
public class SettingsController extends WalletFormController implements Initializable {
@@ -329,6 +323,22 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
@Subscribe
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
updateBirthDate(event.getWalletFile(), event.getWallet());
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
updateBirthDate(event.getWalletFile(), event.getWallet());
}
private void updateBirthDate(File walletFile, Wallet wallet) {
if(walletFile.equals(walletForm.getWalletFile()) && !Objects.equals(wallet.getBirthDate(), walletForm.getWallet().getBirthDate())) {
walletForm.getWallet().setBirthDate(wallet.getBirthDate());
}
}
private void saveWallet(boolean changePassword) {
ECKey existingPubKey = walletForm.getStorage().getEncryptionPubKey();
@@ -345,6 +355,15 @@ public class SettingsController extends WalletFormController implements Initiali
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_SET;
}
if(!changePassword && ((SettingsWalletForm)walletForm).isAddressChange() && !walletForm.getWallet().getTransactions().isEmpty()) {
Optional<ButtonType> optResponse = AppServices.showWarningDialog("Change Wallet Addresses?", "This wallet has existing transactions which will be replaced as the wallet addresses will change. Ok to proceed?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.CANCEL)) {
revert.setDisable(false);
apply.setDisable(false);
return;
}
}
WalletPasswordDialog dlg = new WalletPasswordDialog(requirement);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
@@ -7,6 +7,7 @@ import com.sparrowwallet.sparrow.event.WalletSettingsChangedEvent;
import com.sparrowwallet.sparrow.io.Storage;
import java.io.IOException;
import java.util.Objects;
/**
* This class extends WalletForm to allow rollback of wallet changes. It is used exclusively by SettingsController for this purpose.
@@ -37,7 +38,7 @@ public class SettingsWalletForm extends WalletForm {
@Override
public void saveAndRefresh() throws IOException {
boolean refreshAll = changesScriptHashes(wallet, walletCopy);
boolean refreshAll = isRefreshNecessary(wallet, walletCopy);
if(refreshAll) {
walletCopy.clearNodes();
}
@@ -50,11 +51,31 @@ public class SettingsWalletForm extends WalletForm {
}
}
private boolean changesScriptHashes(Wallet original, Wallet changed) {
private boolean isRefreshNecessary(Wallet original, Wallet changed) {
if(!original.isValid() || !changed.isValid()) {
return true;
}
if(isAddressChange(original, changed)) {
return true;
}
if(original.getGapLimit() != changed.getGapLimit()) {
return true;
}
if(!Objects.equals(original.getBirthDate(), changed.getBirthDate())) {
return true;
}
return false;
}
protected boolean isAddressChange() {
return isAddressChange(wallet, walletCopy);
}
private boolean isAddressChange(Wallet original, Wallet changed) {
if(original.getPolicyType() != changed.getPolicyType()) {
return true;
}
@@ -73,19 +94,15 @@ public class SettingsWalletForm extends WalletForm {
Keystore originalKeystore = original.getKeystores().get(i);
Keystore changedKeystore = changed.getKeystores().get(i);
if(!originalKeystore.getKeyDerivation().equals(changedKeystore.getKeyDerivation())) {
if(!Objects.equals(originalKeystore.getKeyDerivation(), changedKeystore.getKeyDerivation())) {
return true;
}
if(!originalKeystore.getExtendedPublicKey().equals(changedKeystore.getExtendedPublicKey())) {
if(!Objects.equals(originalKeystore.getExtendedPublicKey(), changedKeystore.getExtendedPublicKey())) {
return true;
}
}
if(original.getGapLimit() != changed.getGapLimit()) {
return true;
}
return false;
}
}
@@ -14,11 +14,15 @@ import javafx.beans.property.IntegerProperty;
import javafx.beans.property.IntegerPropertyBase;
import javafx.beans.property.LongProperty;
import javafx.beans.property.LongPropertyBase;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.stream.Collectors;
public class TransactionEntry extends Entry implements Comparable<TransactionEntry> {
private static final Logger log = LoggerFactory.getLogger(TransactionEntry.class);
private final BlockTransaction blockTransaction;
public TransactionEntry(Wallet wallet, BlockTransaction blockTransaction, Map<BlockTransactionHashIndex, KeyPurpose> inputs, Map<BlockTransactionHashIndex, KeyPurpose> outputs) {
@@ -86,6 +90,7 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
if(optRef.isPresent()) {
validEntries++;
if(getChildren().stream().noneMatch(entry -> ((HashIndexEntry)entry).getHashIndex().equals(optRef.get().getSpentBy()) && ((HashIndexEntry)entry).getType().equals(HashIndexEntry.Type.INPUT))) {
log.warn("TransactionEntry " + blockTransaction.getHash() + " for wallet " + getWallet().getName() + " missing child for input " + optRef.get().getSpentBy() + " on output " + optRef.get());
return false;
}
}
@@ -95,12 +100,14 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
if(optRef.isPresent()) {
validEntries++;
if(getChildren().stream().noneMatch(entry -> ((HashIndexEntry)entry).getHashIndex().equals(optRef.get()) && ((HashIndexEntry)entry).getType().equals(HashIndexEntry.Type.OUTPUT))) {
log.warn("TransactionEntry " + blockTransaction.getHash() + " for wallet " + getWallet().getName() + " missing child for output " + optRef.get());
return false;
}
}
}
if(getChildren().size() != validEntries) {
log.warn("TransactionEntry " + blockTransaction.getHash() + " for wallet " + getWallet().getName() + " has incorrect number of children " + getChildren().size() + " (should be " + validEntries + ")");
return false;
}
@@ -4,10 +4,7 @@ import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.BalanceChart;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.control.FiatLabel;
import com.sparrowwallet.sparrow.control.TransactionsTreeTable;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import javafx.collections.ListChangeListener;
@@ -29,6 +26,12 @@ public class TransactionsController extends WalletFormController implements Init
@FXML
private CoinLabel mempoolBalance;
@FXML
private FiatLabel fiatMempoolBalance;
@FXML
private CopyableLabel transactionCount;
@FXML
private TransactionsTreeTable transactionsTable;
@@ -47,10 +50,14 @@ public class TransactionsController extends WalletFormController implements Init
transactionsTable.initialize(walletTransactionsEntry);
balance.valueProperty().addListener((observable, oldValue, newValue) -> {
setFiatBalance(AppServices.getFiatCurrencyExchangeRate(), newValue.longValue());
setFiatBalance(fiatBalance, AppServices.getFiatCurrencyExchangeRate(), newValue.longValue());
});
balance.setValue(walletTransactionsEntry.getBalance());
mempoolBalance.valueProperty().addListener((observable, oldValue, newValue) -> {
setFiatBalance(fiatMempoolBalance, AppServices.getFiatCurrencyExchangeRate(), newValue.longValue());
});
mempoolBalance.setValue(walletTransactionsEntry.getMempoolBalance());
setTransactionCount(walletTransactionsEntry);
balanceChart.initialize(walletTransactionsEntry);
transactionsTable.getSelectionModel().getSelectedIndices().addListener((ListChangeListener<Integer>) c -> {
@@ -61,12 +68,19 @@ public class TransactionsController extends WalletFormController implements Init
});
}
private void setFiatBalance(CurrencyRate currencyRate, long balance) {
if(currencyRate != null && currencyRate.isAvailable()) {
fiatBalance.set(currencyRate, balance);
private void setFiatBalance(FiatLabel fiatLabel, CurrencyRate currencyRate, long balance) {
if(currencyRate != null && currencyRate.isAvailable() && balance > 0) {
fiatLabel.set(currencyRate, balance);
} else {
fiatLabel.setCurrency(null);
fiatLabel.setBtcRate(0.0);
}
}
private void setTransactionCount(WalletTransactionsEntry walletTransactionsEntry) {
transactionCount.setText(walletTransactionsEntry.getChildren() != null ? Integer.toString(walletTransactionsEntry.getChildren().size()) : "0");
}
@Subscribe
public void walletNodesChanged(WalletNodesChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
@@ -76,6 +90,7 @@ public class TransactionsController extends WalletFormController implements Init
balance.setValue(walletTransactionsEntry.getBalance());
mempoolBalance.setValue(walletTransactionsEntry.getMempoolBalance());
balanceChart.update(walletTransactionsEntry);
setTransactionCount(walletTransactionsEntry);
}
}
@@ -91,6 +106,7 @@ public class TransactionsController extends WalletFormController implements Init
balance.setValue(walletTransactionsEntry.getBalance());
mempoolBalance.setValue(walletTransactionsEntry.getMempoolBalance());
balanceChart.update(walletTransactionsEntry);
setTransactionCount(walletTransactionsEntry);
}
}
@@ -115,12 +131,15 @@ public class TransactionsController extends WalletFormController implements Init
if(event.getExchangeSource() == ExchangeSource.NONE) {
fiatBalance.setCurrency(null);
fiatBalance.setBtcRate(0.0);
fiatMempoolBalance.setCurrency(null);
fiatMempoolBalance.setBtcRate(0.0);
}
}
@Subscribe
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatBalance(event.getCurrencyRate(), getWalletForm().getWalletTransactionsEntry().getBalance());
setFiatBalance(fiatBalance, event.getCurrencyRate(), getWalletForm().getWalletTransactionsEntry().getBalance());
setFiatBalance(fiatMempoolBalance, event.getCurrencyRate(), getWalletForm().getWalletTransactionsEntry().getMempoolBalance());
}
@Subscribe
@@ -128,6 +147,21 @@ public class TransactionsController extends WalletFormController implements Init
transactionsTable.updateHistoryStatus(event);
}
@Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
}
@Subscribe
public void bwtScanStatus(BwtScanStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
}
@Subscribe
public void bwtShutdown(BwtShutdownEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), false));
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
@@ -133,6 +133,22 @@ public class UtxosController extends WalletFormController implements Initializab
utxosTable.updateHistoryStatus(event);
}
@Subscribe
public void bwtSyncStatus(BwtSyncStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
}
@Subscribe
public void bwtScanStatus(BwtScanStatusEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), true, event.getStatus()));
}
@Subscribe
public void bwtShutdown(BwtShutdownEvent event) {
walletHistoryStatus(new WalletHistoryStatusEvent(walletForm.getWallet(), false));
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
@@ -75,18 +75,18 @@ public class WalletForm {
public void refreshHistory(Integer blockHeight, WalletNode node) {
Wallet previousWallet = wallet.copy();
if(wallet.isValid() && AppServices.isOnline()) {
log.debug(node == null ? "Refreshing full wallet history" : "Requesting node wallet history for " + node.getDerivationPath());
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, true));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, false));
updateWallet(previousWallet, blockHeight);
});
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 WalletHistoryStatusEvent(wallet, false));
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true));
historyService.start();
}
}
@@ -106,7 +106,7 @@ public class WalletForm {
boolean changed = false;
if(!historyChangedNodes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, historyChangedNodes)));
Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, storage, historyChangedNodes)));
changed = true;
}
@@ -248,7 +248,7 @@ public class WalletForm {
if(wallet.isValid()) {
WalletNode walletNode = event.getWalletNode(wallet);
if(walletNode != null) {
log.debug(wallet.getName() + " history event for node " + walletNode);
log.debug(wallet.getName() + " history event for node " + walletNode + " (" + event.getScriptHash() + ")");
refreshHistory(AppServices.getCurrentBlockHeight(), walletNode);
}
}
+1
View File
@@ -25,4 +25,5 @@ open module com.sparrowwallet.sparrow {
requires centerdevice.nsmenufx;
requires jcommander;
requires slf4j.api;
requires bwt.jni;
}
@@ -8,7 +8,7 @@
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<StackPane prefHeight="420.0" prefWidth="600.0" stylesheets="@about.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,9 @@
</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 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="For privacy and security reasons it is not recommended to use a public Electrum server. Install an Electrum server that connects to your full node to index the blockchain and provide full privacy." 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" />
<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">
<Button text="Done" onAction="#close" />
@@ -29,3 +29,8 @@
-fx-padding: 0 0 0 8;
-fx-spacing: 10;
}
.core-server.toggle-switch:selected .thumb-area {
-fx-background-color: linear-gradient(to bottom, derive(-fx-text-box-border, -20%), derive(-fx-text-box-border, -30%)), linear-gradient(to bottom, derive(#50a14f, 30%), #50a14f);
-fx-background-insets: 0, 1;
}
@@ -168,3 +168,15 @@
-fx-border-width: 1px 0px 1px 0px;
-fx-border-color: derive(-fx-background, -10%);
}
.root .placeholder .hyperlink {
-fx-text-fill: derive(#1e88cf, 20%);
}
.root .box-overlay {
-fx-effect: dropshadow(three-pass-box, black, 14, 0, 0, 0);
}
.root .box-overlay .label {
-fx-text-fill: white;
}
@@ -40,6 +40,10 @@
-fx-padding: -20 0 0 0;
}
.form .field .toggle-switch {
-fx-padding: 5 0 2 0;
}
.tab-error > .tab-container {
-fx-effect: dropshadow(three-pass-box, rgba(202, 18, 67, .6), 7, 0, 0, 0);
}
@@ -155,3 +159,8 @@
.root .header-panel {
-fx-background-color: -fx-box-border, derive(-fx-background, 10%);
}
.box-overlay {
-fx-background-color: -fx-box-border, derive(-fx-background, 10%);
-fx-effect: dropshadow(three-pass-box, #a0a1a7, 14, 0, 0, 0);
}
@@ -12,6 +12,10 @@
<?import tornadofx.control.Field?>
<?import com.sparrowwallet.sparrow.control.UnlabeledToggleSwitch?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import org.controlsfx.control.SegmentedButton?>
<?import com.sparrowwallet.sparrow.net.ServerType?>
<?import com.sparrowwallet.sparrow.net.CoreAuthType?>
<?import com.sparrowwallet.sparrow.control.HelpLabel?>
<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" />
@@ -24,25 +28,92 @@
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Electrum Server">
<Fieldset inputGrow="SOMETIMES" text="Server">
<Field text="Type:">
<SegmentedButton>
<toggleGroup>
<ToggleGroup fx:id="serverTypeToggleGroup" />
</toggleGroup>
<buttons>
<ToggleButton text="Bitcoin Core" toggleGroup="$serverTypeToggleGroup">
<userData>
<ServerType fx:constant="BITCOIN_CORE"/>
</userData>
</ToggleButton>
<ToggleButton text="Electrum Server" toggleGroup="$serverTypeToggleGroup">
<userData>
<ServerType fx:constant="ELECTRUM_SERVER"/>
</userData>
</ToggleButton>
</buttons>
</SegmentedButton>
</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="host" promptText="e.g. 127.0.0.1"/>
<TextField fx:id="port" promptText="e.g. 50002" prefWidth="80" />
<TextField fx:id="coreHost" promptText="e.g. 127.0.0.1"/>
<TextField fx:id="corePort" promptText="e.g. 8332" prefWidth="80" />
</Field>
<Field text="Use SSL:">
<UnlabeledToggleSwitch fx:id="useSsl"/>
<Field text="Authentication:">
<SegmentedButton>
<toggleGroup>
<ToggleGroup fx:id="coreAuthToggleGroup" />
</toggleGroup>
<buttons>
<ToggleButton text="Default" toggleGroup="$coreAuthToggleGroup">
<userData>
<CoreAuthType fx:constant="COOKIE"/>
</userData>
</ToggleButton>
<ToggleButton text="User / Pass" toggleGroup="$coreAuthToggleGroup">
<userData>
<CoreAuthType fx:constant="USERPASS"/>
</userData>
</ToggleButton>
</buttons>
</SegmentedButton>
</Field>
<Field text="Certificate:" styleClass="label-button">
<TextField fx:id="certificate" promptText="Optional server certificate (.crt)"/>
<Button fx:id="certificateSelect" maxWidth="25" minWidth="-Infinity" prefWidth="30" text="Ed">
<Field fx:id="coreDataDirField" text="Data Folder:" styleClass="label-button">
<TextField fx:id="coreDataDir"/>
<Button fx:id="coreDataDirSelect" maxWidth="35" minWidth="-Infinity" prefWidth="35">
<graphic>
<Glyph fontFamily="FontAwesome" icon="EDIT" prefWidth="15" />
<Glyph fontFamily="FontAwesome" icon="EDIT" fontSize="13" />
</graphic>
</Button>
</Field>
<Field fx:id="coreUserPassField" text="User / Pass:" styleClass="label-button">
<TextField fx:id="coreUser"/>
<PasswordField fx:id="corePass"/>
</Field>
<Field text="Multi-Wallet:">
<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"/>
</Field>
</Fieldset>
</Form>
<Fieldset inputGrow="SOMETIMES" text="Proxy">
<Form fx:id="electrumForm" GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="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" />
</Field>
<Field text="Use SSL:">
<UnlabeledToggleSwitch fx:id="electrumUseSsl"/>
</Field>
<Field text="Certificate:" styleClass="label-button">
<TextField fx:id="electrumCertificate" promptText="Optional server certificate (.crt)"/>
<Button fx:id="electrumCertificateSelect" maxWidth="35" minWidth="-Infinity" prefWidth="35">
<graphic>
<Glyph fontFamily="FontAwesome" icon="EDIT" fontSize="13" />
</graphic>
</Button>
</Field>
<Field text="Use Proxy:">
<UnlabeledToggleSwitch fx:id="useProxy"/>
</Field>
@@ -53,7 +124,7 @@
</Fieldset>
</Form>
<StackPane GridPane.columnIndex="0" GridPane.rowIndex="1">
<StackPane GridPane.columnIndex="0" GridPane.rowIndex="2">
<Button fx:id="testConnection" graphicTextGap="5" text="Test Connection">
<graphic>
<Glyph fontFamily="FontAwesome" icon="QUESTION_CIRCLE" prefWidth="13" />
@@ -66,7 +137,7 @@
</Button>
</StackPane>
<StackPane GridPane.columnIndex="0" GridPane.rowIndex="2">
<StackPane GridPane.columnIndex="0" GridPane.rowIndex="3">
<padding>
<Insets top="10.0" bottom="20.0"/>
</padding>
@@ -25,7 +25,11 @@
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Advanced Settings" styleClass="wideLabelFieldSet">
<Fieldset inputGrow="SOMETIMES" text="Advanced Settings">
<Field text="Birth date:">
<DatePicker editable="false" fx:id="birthDate" prefWidth="140" />
<HelpLabel helpText="The date of the earliest transaction (used to avoid scanning the entire blockchain)."/>
</Field>
<Field text="Gap limit:">
<Spinner fx:id="gapLimit" editable="true" prefWidth="90" />
<HelpLabel helpText="Change how far ahead to look for additional transactions beyond the highest derivation with previous transaction outputs."/>
@@ -65,10 +65,6 @@
-fx-alignment: center-left;
}
#transactionDiagram {
-fx-min-height: 230px;
}
#transactionDiagram .boundary {
-fx-stroke: transparent;
}
@@ -127,7 +127,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>
@@ -14,6 +14,7 @@
<?import tornadofx.control.Field?>
<?import com.sparrowwallet.sparrow.control.CoinLabel?>
<?import com.sparrowwallet.sparrow.control.FiatLabel?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
<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>
@@ -32,13 +33,13 @@
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Transactions" styleClass="header">
<Field text="Balance:">
<CoinLabel fx:id="balance"/>
</Field>
<Field text="Fiat balance:">
<FiatLabel fx:id="fiatBalance" />
<CoinLabel fx:id="balance"/><Region HBox.hgrow="ALWAYS"/><FiatLabel fx:id="fiatBalance" minWidth="110" />
</Field>
<Field text="Mempool:">
<CoinLabel fx:id="mempoolBalance" />
<CoinLabel fx:id="mempoolBalance" /><Region HBox.hgrow="ALWAYS"/><FiatLabel fx:id="fiatMempoolBalance" minWidth="110" />
</Field>
<Field text="Transactions:">
<CopyableLabel fx:id="transactionCount" />
</Field>
</Fieldset>
</Form>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
{
"mode": "OFFLINE",
"bitcoinUnit": "AUTO",
"groupByAddress": true,
"includeMempoolChange": true,
"notifyNewTransactions": true,
"checkNewVersions": true,
"theme": "LIGHT",
"useProxy": false
}