Compare commits

..
52 Commits
Author SHA1 Message Date
Craig Raw d6c7a0b757 cache fetched script hash status and dont retrieve history again until it changes 2021-04-29 13:21:52 +02:00
Craig Raw 55181165c8 use read timeout to improve disconnection behaviour, especially while refreshing a wallet 2021-04-29 08:02:56 +02:00
Craig Raw b6f7483bf9 v1.4.0 2021-04-28 13:10:06 +02:00
Craig Raw 0593c764b6 remove socket read timeout 2021-04-28 13:03:58 +02:00
Craig Raw 11a3bfd893 make welcome dialog server toggle uneditable and add tooltip to indicate demonstration only 2021-04-28 12:33:18 +02:00
Craig Raw f1d7fc656c set mnemonic enter words button to number of chosen words when dropdown item clicked 2021-04-28 12:18:35 +02:00
Craig Raw ec918b78ed followup to public server change on wallet history failure 2021-04-28 11:46:46 +02:00
Craig Raw 6f5ee7c695 handle server error conditions better, and if using public server try another 2021-04-28 11:23:17 +02:00
Craig Raw e4264d0199 support toggling trezor passphrase on and off in status bar usb menu 2021-04-27 13:56:32 +02:00
Craig Raw 9feefe8203 show file open/save dialogs on the same screen as the currently focussed window 2021-04-27 12:52:42 +02:00
Craig Raw 93f9539f7b show dialogs on the same screen as the currently focussed window 2021-04-27 12:22:39 +02:00
Craig Raw 8865f4946a follow up to previous commit 2021-04-27 09:44:56 +02:00
Craig Raw 65e13d7b50 fix adding new payment tabs, update last used label on connection 2021-04-27 08:17:30 +02:00
Craig Raw 11189bc605 create wallet transaction in background thread to keep ui responsive, and indicate if long running 2021-04-26 16:45:17 +02:00
Craig Raw 40faaec31e handle user defined fee amount better by requiring a fee rate of 1 sat/vb for utxo selectors 2021-04-26 14:40:11 +02:00
Craig Raw b5b384f5da update transaction tree with final txid when transaction is extracted 2021-04-24 09:04:28 +02:00
Craig Raw 23c802bb5f cpfp follow up 2021-04-24 08:40:48 +02:00
Craig Raw 4567f62ef8 hide show derivation link to fix linux width issue 2021-04-23 12:16:20 +02:00
Craig Raw cd00535212 add create cpfp transaction functionality 2021-04-23 12:05:30 +02:00
Craig Raw 3ff626e2aa always set server toggle tooltip on connection to handle bwt startup 2021-04-22 12:24:09 +02:00
Craig Raw deee63c701 dont revert to tx broadcast state when verbose tx fetch fails 2021-04-22 12:12:42 +02:00
Craig Raw b465b9fdaf various minor ui fixes including update screens after includemempooloutputs changed 2021-04-21 13:09:31 +02:00
Craig Raw f377f97931 add submit bug report to help menu 2021-04-21 10:37:41 +02:00
Craig Raw 58bee33383 always display fresh unused address on startup and when address has been used 2021-04-21 10:23:48 +02:00
Craig Raw 18075e0686 improve variable naming for utxo selection criteria 2021-04-21 09:37:19 +02:00
Craig Raw b836774289 if allow unconfirmed, include mempool outputs in balance #2 2021-04-20 11:57:22 +02:00
Craig Raw f2f15208bf if allow unconfirmed, include mempool outputs in balance 2021-04-20 11:55:43 +02:00
Craig Raw 164742387f disable receive address field once used 2021-04-20 11:46:36 +02:00
Craig Raw e62b44bbbe allow external unconfirmed utxos to be spent 2021-04-20 11:08:44 +02:00
Craig Raw e9d0491b42 move insufficient funds label to below amount field 2021-04-20 10:08:07 +02:00
Craig Raw 3fc2127337 import and support master private key keystores 2021-04-20 08:28:16 +02:00
Craig Raw 702d92d4f2 handle failure to retrieve broadcasted transaction from mempool, allow retry 2021-04-19 10:34:47 +02:00
Craig Raw a440c22455 deduct unconfirmed transactions with negative values from balance 2021-04-19 09:50:09 +02:00
Craig Raw 1c2e635650 spacing followup 2021-04-19 09:34:28 +02:00
Craig Raw 7b856f32a2 add insufficient inputs feedback label 2021-04-19 09:29:19 +02:00
Craig Raw 42b96e042b improve bip39 keystore import ux 2021-04-19 08:41:46 +02:00
Craig Raw f8023e9a72 add (non-final) suffix to fee rate field when tx is lacking signatures 2021-04-16 14:05:53 +02:00
Craig Raw 7b9b78684c show tx hex tooltip input, output and witness indexes 2021-04-16 14:00:12 +02:00
Craig Raw 10d6fb8b2b fix npe on fee rates selection toggle 2021-04-16 12:30:59 +02:00
Craig Raw 1556b8930c null-safe testing of device needspin and needspassphrase 2021-04-16 11:38:01 +02:00
Craig Raw d52bade085 various minor ui improvements 2021-04-16 10:53:05 +02:00
Craig Raw 2c98c8606c add transaction hex tooltips 2021-04-15 15:18:00 +02:00
Craig Raw a8430c79eb allow manual configuration of hwi executable path 2021-04-14 11:25:40 +02:00
Craig Raw 3fe5fd89c4 fix qr display dialog sizing 2021-04-14 10:20:23 +02:00
Craig Raw 78406b3110 use cryptopsbt encoding 2021-04-14 09:17:42 +02:00
Craig Raw 2dfdbd6d78 support ur:crypto-output scan and display of wallet output descriptor 2021-04-13 17:27:42 +02:00
Craig Raw 2e86840e92 reduce opacity for unconfirmed txes 2021-04-12 16:27:10 +02:00
Craig Raw 9600d5707c when importing electrum wallets retrieve tx labels from address labels 2021-04-12 16:16:07 +02:00
Craig Raw 497cf333b0 ensure wallet cannot have negative balance with same block tx ordering 2021-04-12 15:47:45 +02:00
Craig Raw eb55b9420a add option to strip sensitive info from psbt serialization 2021-04-12 13:42:36 +02:00
Craig Raw 3b3e46983f deduplicate output descriptors for bwt 2021-04-12 12:22:51 +02:00
Craig Raw 31fb527218 color transaction values which deduct from wallet balance in red 2021-04-12 09:16:49 +02:00
80 changed files with 1385 additions and 205 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.beryx.jlink' version '2.22.0'
}
def sparrowVersion = '1.3.2'
def sparrowVersion = '1.4.0'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -51,7 +51,7 @@ dependencies {
implementation('com.github.arteam:simple-json-rpc-server:1.0') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet:hummingbird:1.5.5')
implementation('com.sparrowwallet:hummingbird:1.6.0')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
exclude group: 'com.google.android.tools', module: 'dx'
}
+1 -1
Submodule drongo updated: 49654b7c82...db9617ee10
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.3.2</string>
<string>1.4.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -79,6 +79,7 @@ public class AppController implements Initializable {
public static final double TAB_LABEL_GRAPHIC_OPACITY_ACTIVE = 0.95;
public static final String LOADING_TRANSACTIONS_MESSAGE = "Loading wallet, select Transactions tab to view...";
public static final String CONNECTION_FAILED_PREFIX = "Connection failed: ";
public static final String TRYING_ANOTHER_SERVER_MESSAGE = "trying another server...";
@FXML
private MenuItem saveTransaction;
@@ -323,6 +324,10 @@ public class AppController implements Initializable {
}
}
public void submitBugReport(ActionEvent event) throws IOException {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
}
public void showAbout(ActionEvent event) {
Stage aboutStage = getAboutStage();
aboutStage.show();
@@ -380,6 +385,7 @@ public class AppController implements Initializable {
new FileChooser.ExtensionFilter("TXN", "*.txn")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if (file != null) {
openTransactionFile(file);
@@ -519,6 +525,7 @@ public class AppController implements Initializable {
fileChooser.setInitialFileName(fileName);
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(PrintWriter writer = new PrintWriter(file, StandardCharsets.UTF_8)) {
@@ -532,14 +539,22 @@ public class AppController implements Initializable {
}
public void savePSBTBinary(ActionEvent event) {
savePSBT(false);
savePSBT(false, true);
}
public void savePSBTText(ActionEvent event) {
savePSBT(true);
savePSBT(true, true);
}
public void savePSBT(boolean asText) {
public void savePSBTBinaryNoXpubs(ActionEvent event) {
savePSBT(false, false);
}
public void savePSBTTextNoXpubs(ActionEvent event) {
savePSBT(true, false);
}
public void savePSBT(boolean asText, boolean includeXpubs) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
@@ -562,6 +577,7 @@ public class AppController implements Initializable {
fileChooser.setInitialFileName(fileName);
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!asText && !file.getName().toLowerCase().endsWith(".psbt")) {
@@ -571,10 +587,10 @@ public class AppController implements Initializable {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
if(asText) {
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.print(transactionTabData.getPsbt().toBase64String());
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
writer.flush();
} else {
outputStream.write(transactionTabData.getPsbt().serialize());
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs));
}
} catch(IOException e) {
log.error("Error saving PSBT", e);
@@ -703,6 +719,7 @@ public class AppController implements Initializable {
fileChooser.setTitle("Open Wallet");
fileChooser.setInitialDirectory(Storage.getWalletsDir());
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
openWalletFile(file, forceSameWindow);
@@ -779,7 +796,7 @@ public class AppController implements Initializable {
}
private void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException {
if(wallet.containsSeeds()) {
if(wallet.containsPrivateKeys()) {
//Derive xpub and master fingerprint from seed, potentially with passphrase
Wallet copy = wallet.copy();
for(Keystore copyKeystore : copy.getKeystores()) {
@@ -815,6 +832,12 @@ public class AppController implements Initializable {
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
keystore.getSeed().setPassphrase(copyKeystore.getSeed().getPassphrase());
copyKeystore.getSeed().clear();
} else if(keystore.hasMasterPrivateExtendedKey()) {
Keystore copyKeystore = copy.getKeystores().get(i);
Keystore derivedKeystore = Keystore.fromMasterPrivateExtendedKey(copyKeystore.getMasterPrivateExtendedKey(), copyKeystore.getKeyDerivation().getDerivation());
keystore.setKeyDerivation(derivedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(derivedKeystore.getExtendedPublicKey());
copyKeystore.getMasterPrivateKey().clear();
}
}
}
@@ -873,9 +896,11 @@ public class AppController implements Initializable {
File walletFile = Storage.getExistingWallet(wallet.getName());
if(walletFile != null) {
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
AppServices.setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.setTitle("Existing wallet found");
alert.setHeaderText("Replace existing wallet?");
alert.setContentText("Wallet file " + walletFile.getName() + " already exists.\n");
AppServices.moveToActiveWindowScreen(alert);
Optional<ButtonType> result = alert.showAndWait();
if(result.isPresent() && result.get() == ButtonType.CANCEL) {
return;
@@ -958,7 +983,7 @@ public class AppController implements Initializable {
WalletTabData walletTabData = (WalletTabData)tab.getUserData();
Wallet wallet = walletTabData.getWallet();
if(wallet.getKeystores().size() == 1 &&
(wallet.getKeystores().get(0).hasSeed() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
//Can sign and verify
messageSignDialog = new MessageSignDialog(wallet);
}
@@ -985,6 +1010,7 @@ public class AppController implements Initializable {
Wallet pastWallet = wallet.copy();
walletTabData.getStorage().backupTempWallet();
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
}
}
@@ -1515,7 +1541,9 @@ public class AppController implements Initializable {
@Subscribe
public void connectionStart(ConnectionStartEvent event) {
statusUpdated(new StatusEvent(event.getStatus(), 120));
if(!statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
statusUpdated(new StatusEvent(event.getStatus(), 120));
}
}
@Subscribe
@@ -1528,17 +1556,21 @@ public class AppController implements Initializable {
public void connection(ConnectionEvent event) {
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
statusUpdated(new StatusEvent(status));
setServerToggleTooltip(event.getBlockHeight());
}
@Subscribe
public void disconnection(DisconnectionEvent event) {
serverToggle.setDisable(false);
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith(CONNECTION_FAILED_PREFIX)) {
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith(CONNECTION_FAILED_PREFIX) && !statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
statusUpdated(new StatusEvent("Disconnected"));
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
}
for(Wallet wallet : getOpenWallets().keySet()) {
tabLabelStopAnimation(wallet);
}
}
@Subscribe
@@ -1595,6 +1627,11 @@ public class AppController implements Initializable {
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
walletHistoryFinished(new WalletHistoryFinishedEvent(event.getWallet()));
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData) tab.getUserData()).getWallet() == event.getWallet()).forEach(this::tabLabelAddFailure);
if(getOpenWallets().containsKey(event.getWallet())) {
if(AppServices.isConnected()) {
statusUpdated(new StatusEvent("Error retrieving wallet history" + (Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER ? ", " + TRYING_ANOTHER_SERVER_MESSAGE : "")));
}
}
}
@Subscribe
@@ -29,8 +29,11 @@ import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.image.Image;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
@@ -247,8 +250,7 @@ public class AppServices {
onlineProperty.addListener(onlineServicesListener);
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
List<String> otherServers = Arrays.stream(PublicElectrumServer.values()).map(PublicElectrumServer::getUrl).filter(url -> !url.equals(Config.get().getPublicElectrumServer())).collect(Collectors.toList());
Config.get().setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
Config.get().changePublicServer();
connectionService.setPeriod(Duration.seconds(PUBLIC_SERVER_RETRY_PERIOD_SECS));
}
@@ -494,6 +496,10 @@ public class AppServices {
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
public static void clearTransactionHistoryCache(Wallet wallet) {
ElectrumServer.clearRetrievedScriptHashes(wallet);
}
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
}
@@ -508,6 +514,7 @@ public class AppServices {
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@@ -520,6 +527,46 @@ public class AppServices {
}
}
public static Window getActiveWindow() {
return Stage.getWindows().stream().filter(Window::isFocused).findFirst().orElse(get().walletWindows.keySet().iterator().hasNext() ? get().walletWindows.keySet().iterator().next() : null);
}
public static void moveToActiveWindowScreen(Dialog<?> dialog) {
Window activeWindow = getActiveWindow();
if(activeWindow != null) {
moveToWindowScreen(activeWindow, dialog);
}
}
public static void moveToActiveWindowScreen(Window newWindow, double newWindowWidth, double newWindowHeight) {
Window activeWindow = getActiveWindow();
if(activeWindow != null) {
moveToWindowScreen(activeWindow, newWindow, newWindowWidth, newWindowHeight);
}
}
public void moveToWalletWindowScreen(Storage storage, Dialog<?> dialog) {
moveToWindowScreen(getWindowForWallet(storage), dialog);
}
public static void moveToWindowScreen(Window currentWindow, Dialog<?> dialog) {
Window newWindow = dialog.getDialogPane().getScene().getWindow();
DialogPane dialogPane = dialog.getDialogPane();
double dialogWidth = dialogPane.getPrefWidth() > 0.0 ? dialogPane.getPrefWidth() : (dialogPane.getWidth() > 0.0 ? dialogPane.getWidth() : 360);
double dialogHeight = dialogPane.getPrefHeight() > 0.0 ? dialogPane.getPrefHeight() : (dialogPane.getHeight() > 0.0 ? dialogPane.getHeight() : 200);
moveToWindowScreen(currentWindow, newWindow, dialogWidth, dialogHeight);
}
public static void moveToWindowScreen(Window currentWindow, Window newWindow, double newWindowWidth, double newWindowHeight) {
Screen currentScreen = Screen.getScreens().stream().filter(screen -> screen.getVisualBounds().contains(currentWindow.getX(), currentWindow.getY())).findFirst().orElse(null);
if(currentScreen != null && !Screen.getPrimary().getVisualBounds().contains(currentWindow.getX(), currentWindow.getY()) && !currentScreen.getVisualBounds().contains(newWindow.getX(), newWindow.getY())) {
double x = currentWindow.getX() + (currentWindow.getWidth() / 2) - (newWindowWidth / 2);
double y = currentWindow.getY() + (currentWindow.getHeight() / 2.2) - (newWindowHeight / 2);
newWindow.setX(x);
newWindow.setY(y);
}
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
}
@@ -616,6 +663,10 @@ public class AppServices {
deviceEnumerateService = createDeviceEnumerateService();
}
if(deviceEnumerateService.isRunning()) {
deviceEnumerateService.cancel();
}
if(deviceEnumerateService.getState() == Worker.State.CANCELLED) {
deviceEnumerateService.reset();
}
@@ -667,4 +718,14 @@ public class AppServices {
connectionService.start();
}
}
@Subscribe
public void walletHistoryFailed(WalletHistoryFailedEvent event) {
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && isConnected()) {
onlineProperty.set(false);
log.info("Failed to fetch wallet history from " + Config.get().getServerAddress() + ", reconnecting to another server...");
Config.get().changePublicServer();
onlineProperty.set(true);
}
}
}
@@ -30,7 +30,7 @@ import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.3.2";
public static final String APP_VERSION = "1.4.0";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -7,7 +7,10 @@ import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.EventType;
import javafx.fxml.FXML;
import javafx.scene.control.Tooltip;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.VBox;
@@ -48,6 +51,10 @@ public class WelcomeController {
welcomeBox.getStyleClass().add("offline");
serverStatus.setText("Offline");
serverToggle.addEventFilter(MouseEvent.MOUSE_RELEASED, Event::consume);
Tooltip tooltip = new Tooltip("Demonstration only - you are not connected!");
tooltip.setShowDelay(Duration.ZERO);
serverToggle.setTooltip(tooltip);
serverToggle.selectedProperty().addListener((observable, oldValue, newValue) -> {
serverStatus.setText(newValue ? "Connected (demonstration only)" : "Offline");
});
@@ -21,6 +21,7 @@ public class WelcomeDialog extends Dialog<Mode> {
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("welcome.css").toExternalForm());
@@ -66,6 +66,10 @@ class CoinCell extends TreeTableCell<Entry, Number> {
} else {
setGraphic(null);
}
if(amount.longValue() < 0) {
getStyleClass().add("negative-amount");
}
} else if(entry instanceof UtxoEntry) {
setGraphic(null);
} else if(entry instanceof HashIndexEntry) {
@@ -89,6 +89,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(360);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == cancelButtonType ? null : getResult());
}
@@ -114,7 +115,7 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
if(operationFingerprints != null) {
dialogDevices.removeIf(device -> {
return device.getFingerprint() != null && !operationFingerprints.contains(device.getFingerprint()) && !(device.getNeedsPinSent() || device.getNeedsPassphraseSent());
return device.getFingerprint() != null && !operationFingerprints.contains(device.getFingerprint()) && !(device.isNeedsPinSent() || device.isNeedsPassphraseSent());
});
}
@@ -144,9 +144,9 @@ public class DevicePane extends TitledDescriptionPane {
}
private void initialise(Device device) {
if(device.getNeedsPinSent() != null && device.getNeedsPinSent()) {
if(device.isNeedsPinSent()) {
unlockButton.setVisible(true);
} else if(device.getNeedsPassphraseSent() != null && device.getNeedsPassphraseSent()) {
} else if(device.isNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
} else if(device.getError() != null) {
setError("Error", device.getError());
@@ -165,7 +165,7 @@ public class DevicePane extends TitledDescriptionPane {
}
private void setDefaultStatus() {
setDescription(device.getNeedsPinSent() ? "Locked" : device.getNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
setDescription(device.isNeedsPinSent() ? "Locked" : device.isNeedsPassphraseSent() ? "Passphrase Required" : "Unlocked");
}
private void createUnlockButton() {
@@ -411,7 +411,7 @@ public class DevicePane extends TitledDescriptionPane {
setExpanded(false);
unlockButton.setVisible(false);
if(device.getNeedsPassphraseSent()) {
if(device.isNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
setPassphraseButton.setDisable(true);
setContent(getPassphraseEntry());
@@ -92,6 +92,17 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(increaseFeeButton);
}
if(blockTransaction.getHeight() <= 0 && containsWalletOutputs(transactionEntry)) {
Button cpfpButton = new Button("");
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
cpfpGlyph.setFontSize(12);
cpfpButton.setGraphic(cpfpGlyph);
cpfpButton.setOnAction(event -> {
createCpfp(transactionEntry);
});
actionBox.getChildren().add(cpfpButton);
}
setGraphic(actionBox);
} else if(entry instanceof NodeEntry) {
NodeEntry nodeEntry = (NodeEntry)entry;
@@ -115,7 +126,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(receiveButton);
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
(nodeEntry.getWallet().getKeystores().get(0).hasSeed() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
(nodeEntry.getWallet().getKeystores().get(0).hasPrivateKey() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
Button signMessageButton = new Button("");
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
@@ -245,6 +256,37 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
return AppServices.getTargetBlockFeeRates().values().iterator().next();
}
private static void createCpfp(TransactionEntry transactionEntry) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
List<BlockTransactionHashIndex> ourOutputs = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
.map(HashIndexEntry::getHashIndex)
.collect(Collectors.toList());
if(ourOutputs.isEmpty()) {
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend");
}
BlockTransactionHashIndex utxo = ourOutputs.get(0);
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
Payment payment = new Payment(transactionEntry.getWallet().getAddress(freshNode), label, utxo.getValue(), true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), blockTransaction.getFee(), false)));
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
return transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.anyMatch(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT));
}
private static void sendSelectedUtxos(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
List<HashIndexEntry> utxoEntries = treeTableView.getSelectionModel().getSelectedCells().stream()
.map(tp -> tp.getTreeItem().getValue())
@@ -286,7 +328,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(copyTxid);
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee");
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setOnAction(AE -> {
hide();
increaseFee(transactionEntry);
@@ -294,6 +336,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(increaseFee);
}
if(containsWalletOutputs(transactionEntry)) {
MenuItem createCpfp = new MenuItem("Increase Effective Fee (CPFP)");
createCpfp.setOnAction(AE -> {
hide();
createCpfp(transactionEntry);
});
getItems().add(createCpfp);
}
}
}
@@ -416,6 +468,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
cell.getStyleClass().remove("address-cell");
cell.getStyleClass().remove("hashindex-row");
cell.getStyleClass().remove("confirming");
cell.getStyleClass().remove("negative-amount");
cell.getStyleClass().remove("spent");
cell.getStyleClass().remove("unspendable");
@@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.FileImport;
import com.sparrowwallet.sparrow.io.ImportException;
@@ -95,6 +96,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
new FileChooser.ExtensionFilter("TXT", "*.txt")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
importFile(file, null);
@@ -87,6 +87,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "") +
(extension == null || extension.isEmpty() ? "" : "." + extension));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
exportWallet(file);
@@ -30,6 +30,7 @@ public class KeystorePassphraseDialog extends Dialog<String> {
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL, ButtonType.OK);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(200);
AppServices.moveToActiveWindowScreen(this);
Glyph key = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.KEY);
key.setFontSize(50);
@@ -0,0 +1,49 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
public class MasterKeyDisplayDialog extends Dialog<Void> {
public MasterKeyDisplayDialog(Keystore decryptedKeystore) {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
StackPane stackPane = new StackPane();
dialogPane.setContent(stackPane);
AnchorPane anchorPane = new AnchorPane();
ScrollPane scrollPane = new ScrollPane();
scrollPane.getStyleClass().add("edge-to-edge");
scrollPane.setPrefHeight(200);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
anchorPane.getChildren().add(scrollPane);
scrollPane.setFitToWidth(true);
AnchorPane.setLeftAnchor(scrollPane, 0.0);
AnchorPane.setRightAnchor(scrollPane, 0.0);
Accordion keystoreAccordion = new Accordion();
scrollPane.setContent(keystoreAccordion);
XprvKeystoreImportPane keystorePane = new XprvKeystoreImportPane(decryptedKeystore);
keystorePane.setAnimated(false);
keystoreAccordion.getPanes().add(keystorePane);
stackPane.getChildren().addAll(anchorPane);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(260);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
}
}
@@ -73,7 +73,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasSeed() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed or USB keystore");
}
}
@@ -185,6 +185,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
EventManager.get().unregister(this);
});
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
}
@@ -211,7 +212,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
return;
}
if(wallet.containsSeeds()) {
if(wallet.containsPrivateKeys()) {
if(wallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
@@ -230,6 +231,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
signature.clear();
signature.appendText(signatureText);
privKey.clear();
} catch(Exception e) {
log.error("Could not sign message", e);
AppServices.showErrorDialog("Could not sign message", e.getMessage());
@@ -274,6 +276,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
alert.setTitle("Verification Succeeded");
alert.setHeaderText("Verification Succeeded");
alert.setContentText("The signature verified against the message.");
AppServices.moveToActiveWindowScreen(alert);
alert.showAndWait();
} else {
AppServices.showErrorDialog("Verification failed", "The provided signature did not match the message for this address.");
@@ -316,6 +319,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
signUnencryptedKeystore(decryptedWallet);
decryptedWallet.clearPrivate();
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
@@ -8,6 +8,8 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
@@ -56,6 +58,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private SimpleListProperty<String> wordEntriesProperty;
private final SimpleStringProperty passphraseProperty = new SimpleStringProperty();
private IntegerProperty defaultWordSizeProperty;
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer) {
super(importer.getName(), "Seed import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
@@ -67,7 +70,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
public MnemonicKeystoreImportPane(Keystore keystore) {
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase enabled" : "Passphrase disabled", "", "image/" + WalletModel.SEED.getType() + ".png");
super(keystore.getSeed().getType().getName(), keystore.getSeed().needsPassphrase() ? "Passphrase entered" : "No passphrase", "", "image/" + WalletModel.SEED.getType() + ".png");
this.wallet = null;
this.importer = null;
showHideLink.setVisible(false);
@@ -85,15 +88,20 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private void createEnterMnemonicButton() {
enterMnemonicButton = new SplitMenuButton();
enterMnemonicButton.setAlignment(Pos.CENTER_RIGHT);
enterMnemonicButton.setText("Set Words Size");
enterMnemonicButton.setText("Enter 24 Words");
defaultWordSizeProperty = new SimpleIntegerProperty(24);
defaultWordSizeProperty.addListener((observable, oldValue, newValue) -> {
enterMnemonicButton.setText("Enter " + newValue + " Words");
});
enterMnemonicButton.setOnAction(event -> {
enterMnemonic(24);
enterMnemonic(defaultWordSizeProperty.get());
});
int[] numberWords = new int[] {24, 21, 18, 15, 12};
for(int i = 0; i < numberWords.length; i++) {
MenuItem item = new MenuItem(numberWords[i] + " words");
MenuItem item = new MenuItem("Enter " + numberWords[i] + " Words");
final int words = numberWords[i];
item.setOnAction(event -> {
defaultWordSizeProperty.set(words);
enterMnemonic(words);
});
enterMnemonicButton.getItems().add(item);
@@ -110,7 +118,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
});
String[] accounts = new String[] {"Default Account #0", "Account #1", "Account #2", "Account #3", "Account #4", "Account #5", "Account #6", "Account #7", "Account #8", "Account #9"};
String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
@@ -202,14 +210,14 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
confirmButton.setDefaultButton(true);
confirmButton.setTooltip(new Tooltip("Re-enter the generated word list to confirm your backup is correct"));
calculateButton = new Button("Calculate Seed");
calculateButton = new Button("Create Keystore");
calculateButton.setDisable(true);
calculateButton.setDefaultButton(true);
calculateButton.setOnAction(event -> {
prepareImport();
});
calculateButton.managedProperty().bind(calculateButton.visibleProperty());
calculateButton.setTooltip(new Tooltip("Calculate the seed from the provided word list"));
calculateButton.setTooltip(new Tooltip("Create the keystore from the provided word list"));
backButton = new Button("Back");
backButton.setOnAction(event -> {
@@ -360,7 +368,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
importButton.setDisable(false);
setDescription("Ready to import");
showHideLink.setText("Show Derivation...");
showHideLink.setVisible(true);
showHideLink.setVisible(false);
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
}
}
@@ -499,7 +507,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importDerivationButton = new Button("Import");
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
importDerivationButton.setDisable(true);
importDerivationButton.setOnAction(event -> {
showHideLink.setVisible(true);
setExpanded(false);
@@ -508,7 +517,8 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
});
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue));
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
});
HBox contentBox = new HBox();
@@ -37,6 +37,9 @@ public class QRDisplayDialog extends Dialog<UR> {
private static final int ANIMATION_PERIOD_MILLIS = 400;
private static final int QR_WIDTH = 480;
private static final int QR_HEIGHT = 480;
private final UR ur;
private final UREncoder encoder;
@@ -91,8 +94,9 @@ public class QRDisplayDialog extends Dialog<UR> {
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
}
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(550);
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
}
@@ -113,8 +117,9 @@ public class QRDisplayDialog extends Dialog<UR> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(550);
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
}
@@ -135,7 +140,7 @@ public class QRDisplayDialog extends Dialog<UR> {
private Image getQrCode(String fragment) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, 480, 480);
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
@@ -137,6 +137,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
dialogPane.getButtonTypes().addAll(hdButtonType, cancelButtonType);
dialogPane.setPrefWidth(646);
dialogPane.setPrefHeight(webcamResolutionProperty.get() == WebcamResolution.HD ? 490 : 590);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? result : null);
}
@@ -465,7 +466,12 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
return wallets;
}
private ScriptType getScriptType(List<ScriptExpression> expressions) {
private ScriptType getScriptType(List<ScriptExpression> scriptExpressions) {
List<ScriptExpression> expressions = new ArrayList<>(scriptExpressions);
if(expressions.get(expressions.size() - 1) == ScriptExpression.MULTISIG || expressions.get(expressions.size() - 1) == ScriptExpression.SORTED_MULTISIG) {
expressions.remove(expressions.size() - 1);
}
if(List.of(ScriptExpression.PUBLIC_KEY_HASH).equals(expressions)) {
return ScriptType.P2PKH;
} else if(List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH).equals(expressions)) {
@@ -44,6 +44,7 @@ public class SeedDisplayDialog extends Dialog<Void> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(150 + height);
AppServices.moveToActiveWindowScreen(this);
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
}
@@ -1,13 +1,19 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.NamedArg;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.controlsfx.glyphfont.Glyph;
import java.util.Optional;
public class TextAreaDialog extends Dialog<String> {
private final TextArea textArea;
@@ -18,7 +24,8 @@ public class TextAreaDialog extends Dialog<String> {
}
public TextAreaDialog(@NamedArg("defaultValue") String defaultValue) {
final DialogPane dialogPane = getDialogPane();
final DialogPane dialogPane = new TextAreaDialogPane();
setDialogPane(dialogPane);
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
@@ -40,6 +47,9 @@ public class TextAreaDialog extends Dialog<String> {
dialogPane.getStyleClass().add("text-input-dialog");
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(scanButtonType);
Platform.runLater(textArea::requestFocus);
setResultConverter((dialogButton) -> {
@@ -49,6 +59,7 @@ public class TextAreaDialog extends Dialog<String> {
dialogPane.setPrefWidth(700);
dialogPane.setPrefHeight(400);
AppServices.moveToActiveWindowScreen(this);
}
public final TextArea getEditor() {
@@ -58,4 +69,53 @@ public class TextAreaDialog extends Dialog<String> {
public final String getDefaultValue() {
return defaultValue;
}
private class TextAreaDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button scanButton = new Button(buttonType.getText());
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(scanButton, buttonData);
scanButton.setOnAction(event -> {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.payload != null) {
textArea.setText(result.payload);
} else if(result.psbt != null) {
textArea.setText(result.psbt.toBase64String());
} else if(result.transaction != null) {
textArea.setText(Utils.bytesToHex(result.transaction.bitcoinSerialize()));
} else if(result.uri != null) {
textArea.setText(result.uri.toString());
} else if(result.extendedKey != null) {
textArea.setText(result.extendedKey.getExtendedKey());
} else if(result.outputDescriptor != null) {
textArea.setText(result.outputDescriptor.toString(true));
} else if(result.exception != null) {
AppServices.showErrorDialog("Error scanning QR", result.exception.getMessage());
}
}
});
button = scanButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
}
@@ -49,6 +49,30 @@ public class TransactionDiagram extends GridPane {
}
}
public void update(String message) {
setMinHeight(getDiagramHeight());
setMaxHeight(getDiagramHeight());
getChildren().clear();
VBox messagePane = new VBox();
messagePane.setPrefHeight(getDiagramHeight());
messagePane.setPadding(new Insets(0, 10, 0, 280));
messagePane.setAlignment(Pos.CENTER);
messagePane.getChildren().add(createSpacer());
Label messageLabel = new Label(message);
messagePane.getChildren().add(messageLabel);
messagePane.getChildren().add(createSpacer());
GridPane.setConstraints(messagePane, 3, 0);
getChildren().add(messagePane);
}
public void clear() {
getChildren().clear();
}
public void update() {
Map<BlockTransactionHashIndex, WalletNode> displayedUtxos = getDisplayedUtxos();
@@ -3,13 +3,19 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import javafx.geometry.Point2D;
import javafx.scene.control.Label;
import javafx.stage.Popup;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.event.MouseOverTextEvent;
import org.fxmisc.richtext.model.StyleSpan;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.time.Duration;
import java.util.*;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
public class TransactionHexArea extends CodeArea {
private static final int TRUNCATE_AT = 30000;
@@ -17,6 +23,11 @@ public class TransactionHexArea extends CodeArea {
private List<TransactionSegment> previousSegmentList = new ArrayList<>();
public TransactionHexArea() {
super();
addPopupHandler();
}
public void setTransaction(Transaction transaction) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -44,12 +55,33 @@ public class TransactionHexArea extends CodeArea {
previousSegmentList = segments;
}
private void addPopupHandler() {
Popup popup = new Popup();
Label popupMsg = new Label();
popupMsg.getStyleClass().add("tooltip");
popup.getContent().add(popupMsg);
setMouseOverTextDelay(Duration.ofMillis(150));
addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
Position position = getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex() + 1, Backward);
StyleSpan<Collection<String>> styleSpan = getParagraph(0).getStyleSpans().getStyleSpan(position.getMajor());
Point2D pos = e.getScreenPosition();
popupMsg.setText(describeTransactionPart(styleSpan.getStyle()));
if(!popupMsg.getText().isEmpty()) {
popup.show(this, pos.getX(), pos.getY() + 10);
}
});
addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
popup.hide();
});
}
private void applyHighlighting(int start, List<TransactionSegment> segments) {
int end = Math.min(segments.size(), start + SEGMENTS_INTERVAL);
for(int i = start; i < end; i++) {
TransactionSegment segment = segments.get(i);
if(segment.start < TRUNCATE_AT) {
setStyleClass(segment.start, Math.min(TRUNCATE_AT, segment.start + segment.length), segment.style);
setStyle(segment.start, Math.min(TRUNCATE_AT, segment.start + segment.length), getStyles(segment));
}
}
@@ -60,6 +92,18 @@ public class TransactionHexArea extends CodeArea {
}
}
private Collection<String> getStyles(TransactionSegment segment) {
List<String> styles = new ArrayList<>();
styles.add(segment.style);
if(segment.index != null) {
styles.add("index-" + segment.index);
}
if(segment.witnessIndex != null) {
styles.add("witnessindex-" + segment.witnessIndex);
}
return Collections.unmodifiableList(styles);
}
public List<TransactionSegment> getTransactionSegments(Transaction transaction, int selectedInputIndex, int selectedOutputIndex) {
List<TransactionSegment> segments = new ArrayList<>();
@@ -82,12 +126,12 @@ public class TransactionHexArea extends CodeArea {
//Inputs
for(int i = 0; i < transaction.getInputs().size(); i++) {
TransactionInput input = transaction.getInputs().get(i);
cursor = addSegment(segments, cursor, 32 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "hash"));
cursor = addSegment(segments, cursor, 4 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "index"));
cursor = addSegment(segments, cursor, 32 * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "hash"));
cursor = addSegment(segments, cursor, 4 * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "index"));
VarInt scriptLen = new VarInt(input.getScriptBytes().length);
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript-length"));
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript"));
cursor = addSegment(segments, cursor, 4 * 2, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sequence"));
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript-length"));
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sigscript"));
cursor = addSegment(segments, cursor, 4 * 2, i, "input-" + getIndexedStyleClass(i, selectedInputIndex, "sequence"));
}
//Number of outputs
@@ -97,10 +141,10 @@ public class TransactionHexArea extends CodeArea {
//Outputs
for(int i = 0; i < transaction.getOutputs().size(); i++) {
TransactionOutput output = transaction.getOutputs().get(i);
cursor = addSegment(segments, cursor, 8 * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "value"));
cursor = addSegment(segments, cursor, 8 * 2, i, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "value"));
VarInt scriptLen = new VarInt(output.getScriptBytes().length);
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript-length"));
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript"));
cursor = addSegment(segments, cursor, scriptLen.getSizeInBytes() * 2, i, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript-length"));
cursor = addSegment(segments, cursor, (int) scriptLen.value * 2, i, "output-" + getIndexedStyleClass(i, selectedOutputIndex, "pubkeyscript"));
}
if(transaction.hasWitnesses()) {
@@ -109,11 +153,12 @@ public class TransactionHexArea extends CodeArea {
if (input.hasWitness()) {
TransactionWitness witness = input.getWitness();
VarInt witnessCount = new VarInt(witness.getPushCount());
cursor = addSegment(segments, cursor, witnessCount.getSizeInBytes() * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "count"));
for (byte[] push : witness.getPushes()) {
cursor = addSegment(segments, cursor, witnessCount.getSizeInBytes() * 2, i, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "count"));
for(int j = 0; j < witness.getPushes().size(); j++) {
byte[] push = witness.getPushes().get(j);
VarInt witnessLen = new VarInt(push.length);
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
}
}
}
@@ -131,7 +176,15 @@ public class TransactionHexArea extends CodeArea {
}
private int addSegment(List<TransactionSegment> segments, int start, int length, String style) {
segments.add(new TransactionSegment(start, length, style));
return addSegment(segments, start, length, null, style);
}
private int addSegment(List<TransactionSegment> segments, int start, int length, Integer index, String style) {
return addSegment(segments, start, length, index, null, style);
}
private int addSegment(List<TransactionSegment> segments, int start, int length, Integer index, Integer witnessIndex, String style) {
segments.add(new TransactionSegment(start, length, index, witnessIndex, style));
return start + length;
}
@@ -143,15 +196,59 @@ public class TransactionHexArea extends CodeArea {
return "other";
}
private String describeTransactionPart(Collection<String> styles) {
String style = "";
Integer index = null;
Integer witnessIndex = null;
Iterator<String> iter = styles.iterator();
if(iter.hasNext()) {
style = iter.next();
}
while(iter.hasNext()) {
String indexStyle = iter.next();
if(indexStyle.startsWith("index-")) {
index = Integer.parseInt(indexStyle.substring("index-".length()));
}
if(indexStyle.startsWith("witnessindex-")) {
witnessIndex = Integer.parseInt(indexStyle.substring("witnessindex-".length()));
}
}
return switch(style) {
case "version" -> "Transaction version";
case "segwit-marker" -> "Segwit marker";
case "segwit-flag" -> "Segwit flag";
case "num-inputs" -> "Number of inputs";
case "input-hash" -> "Input #" + index + " outpoint txid";
case "input-index" -> "Input #" + index + " outpoint index";
case "input-sigscript-length" -> "Input #" + index + " scriptSig length";
case "input-sigscript" -> "Input #" + index + " scriptSig";
case "input-sequence" -> "Input #" + index + " sequence";
case "num-outputs" -> "Number of outputs";
case "output-value" -> "Output #" + index + " value";
case "output-pubkeyscript-length" -> "Output #" + index + " scriptPubKey length";
case "output-pubkeyscript" -> "Output #" + index + " scriptPubKey";
case "witness-count" -> "Input #" + index + " witness count";
case "witness-length" -> "Input #" + index + " witness #" + witnessIndex + " length";
case "witness-data" -> "Input #" + index + " witness #" + witnessIndex + " data";
case "locktime" -> "Locktime";
default -> "";
};
}
private static class TransactionSegment {
public TransactionSegment(int start, int length, String style) {
public TransactionSegment(int start, int length, Integer index, Integer witnessIndex, String style) {
this.start = start;
this.length = length;
this.index = index;
this.witnessIndex = witnessIndex;
this.style = style;
}
public int start;
public int length;
public Integer index;
public Integer witnessIndex;
public String style;
@Override
@@ -63,5 +63,6 @@ public class TransactionIdDialog extends Dialog<Sha256Hash> {
txid.setPromptText("f4184fc596403b9d638783cf57adfe4c75c605f6356fbc91338530e9831e9e16");
Platform.runLater(txid::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? Sha256Hash.wrap(txid.getText()) : null);
AppServices.moveToActiveWindowScreen(this);
}
}
@@ -1,8 +1,13 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import javafx.geometry.Side;
import javafx.scene.Node;
import javafx.scene.control.*;
@@ -22,7 +27,23 @@ public class UsbStatusButton extends MenuButton {
public void setDevices(List<Device> devices) {
for(Device device : devices) {
MenuItem deviceItem = new MenuItem(device.getModel().toDisplayString());
if(device.getNeedsPinSent()) {
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T || device.getModel() == WalletModel.KEEPKEY)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase " + (device.isNeedsPassphraseSent() ? "Off" : "On"));
toggleItem.setOnAction(event -> {
Hwi.TogglePassphraseService togglePassphraseService = new Hwi.TogglePassphraseService(device);
togglePassphraseService.setOnSucceeded(event1 -> {
EventManager.get().post(new RequestOpenWalletsEvent());
});
togglePassphraseService.setOnFailed(event1 -> {
AppServices.showErrorDialog("Error toggling passphrase", event1.getSource().getException().getMessage());
});
togglePassphraseService.start();
});
((Menu)deviceItem).getItems().add(toggleItem);
}
if(device.isNeedsPinSent()) {
deviceItem.setGraphic(getLockIcon());
} else {
deviceItem.setGraphic(getLockOpenIcon());
@@ -72,7 +72,7 @@ public class UtxosChart extends BarChart<String, Number> {
private String getCategoryName(Entry entry) {
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
return entry.getLabel().length() > 15 ? entry.getLabel().substring(0, 15) + "..." : entry.getLabel();
return entry.getLabel().length() > 15 ? entry.getLabel().substring(0, 15) + "..." : entry.getLabel() + "\n" + ((UtxoEntry)entry).getDescription();
}
return ((UtxoEntry)entry).getDescription();
@@ -31,6 +31,7 @@ public class WalletBirthDateDialog extends Dialog<Date> {
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(420);
dialogPane.setPrefHeight(200);
AppServices.moveToActiveWindowScreen(this);
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HISTORY);
wallet.setFontSize(50);
@@ -59,6 +59,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(480);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
}
@@ -73,6 +73,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(500);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? wallet : null);
}
@@ -44,6 +44,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(460);
dialogPane.setPrefHeight(requestBirthDate ? 250 : 200);
AppServices.moveToActiveWindowScreen(this);
Glyph wallet = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET);
wallet.setFontSize(50);
@@ -45,6 +45,7 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(380);
dialogPane.setPrefHeight(260);
AppServices.moveToActiveWindowScreen(this);
Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK);
lock.setFontSize(50);
@@ -0,0 +1,209 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreXprvImport;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import java.util.List;
public class XprvKeystoreImportPane extends TitledDescriptionPane {
protected final Wallet wallet;
protected final KeystoreXprvImport importer;
private Button enterXprvButton;
private SplitMenuButton importButton;
private ExtendedKey xprv;
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer) {
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.importer = importer;
createImportButton();
buttonBox.getChildren().add(importButton);
}
public XprvKeystoreImportPane(Keystore keystore) {
super("Master Private Key", "BIP32 key", "", "image/" + WalletModel.SEED.getType() + ".png");
this.wallet = null;
this.importer = null;
try {
this.xprv = keystore.getExtendedMasterPrivateKey();
} catch(MnemonicException e) {
//can't happen
}
showHideLink.setVisible(false);
buttonBox.getChildren().clear();
setContent(getXprvEntry(true));
setExpanded(true);
}
@Override
protected Control createButton() {
enterXprvButton = new Button("Enter Private Key");
enterXprvButton.managedProperty().bind(enterXprvButton.visibleProperty());
enterXprvButton.setOnAction(event -> {
enterXprvButton.setDisable(true);
enterXprv();
});
return enterXprvButton;
}
private void createImportButton() {
importButton = new SplitMenuButton();
importButton.setAlignment(Pos.CENTER_RIGHT);
importButton.setText("Import Keystore");
importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation());
});
String[] accounts = new String[] {"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"};
int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length;
for(int i = 0; i < scriptAccountsLength; i++) {
MenuItem item = new MenuItem(accounts[i]);
final List<ChildNumber> derivation = wallet.getScriptType().getDefaultDerivation(i);
item.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(derivation);
});
importButton.getItems().add(item);
}
importButton.managedProperty().bind(importButton.visibleProperty());
importButton.setVisible(false);
}
private void enterXprv() {
setDescription("Enter master private key");
showHideLink.setVisible(false);
setContent(getXprvEntry(false));
setExpanded(true);
}
private void importKeystore(List<ChildNumber> derivation) {
importButton.setDisable(true);
try {
Keystore keystore = importer.getKeystore(derivation, xprv);
EventManager.get().post(new KeystoreImportEvent(keystore));
} catch (ImportException e) {
String errorMessage = e.getMessage();
if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) {
errorMessage = e.getCause().getMessage();
}
setError("Import Error", errorMessage);
importButton.setDisable(false);
}
}
private Node getXprvEntry(boolean displayOnly) {
TextArea xprvField = new TextArea();
xprvField.setPrefRowCount(2);
xprvField.setWrapText(true);
xprvField.getStyleClass().add("fixed-width");
xprvField.setPromptText(ExtendedKey.Header.fromScriptType(ScriptType.P2PKH, true).getName() + (wallet != null ? "/" + ExtendedKey.Header.fromScriptType(wallet.getScriptType(), true).getName() : "") + "...");
HBox.setHgrow(xprvField, Priority.ALWAYS);
if(xprv != null) {
xprvField.setText(xprv.toString());
}
if(displayOnly) {
xprvField.setEditable(false);
}
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.registerValidator(xprvField, Validator.combine(
Validator.createEmptyValidator("xprv is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid private key", !ExtendedKey.isValid(newValue) || ExtendedKey.fromDescriptor(newValue).getKey().isPubKeyOnly())
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importXprvButton = new Button("Import");
importXprvButton.setMinWidth(80);
importXprvButton.setDisable(true);
importXprvButton.setOnAction(event -> {
enterXprvButton.setVisible(false);
importButton.setVisible(true);
setDescription("Ready to import");
xprv = ExtendedKey.fromDescriptor(xprvField.getText());
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
});
xprvField.textProperty().addListener((observable, oldValue, newValue) -> {
importXprvButton.setDisable(newValue.isEmpty() || !ExtendedKey.isValid(newValue) || ExtendedKey.fromDescriptor(newValue).getKey().isPubKeyOnly());
});
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(xprvField);
if(!displayOnly) {
contentBox.getChildren().add(importXprvButton);
}
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(100);
return contentBox;
}
private Node getDerivationEntry(List<ChildNumber> derivation) {
TextField derivationField = new TextField();
derivationField.setPromptText("Derivation path");
derivationField.setText(KeyDerivation.writePath(derivation));
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
importDerivationButton.setDisable(true);
importDerivationButton.setOnAction(event -> {
showHideLink.setVisible(true);
setExpanded(false);
List<ChildNumber> importDerivation = KeyDerivation.parsePath(derivationField.getText());
importKeystore(importDerivation);
});
derivationField.textProperty().addListener((observable, oldValue, newValue) -> {
importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation));
importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation));
});
HBox contentBox = new HBox();
contentBox.setAlignment(Pos.TOP_RIGHT);
contentBox.setSpacing(20);
contentBox.getChildren().add(derivationField);
contentBox.getChildren().add(importDerivationButton);
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
return contentBox;
}
}
@@ -0,0 +1,8 @@
package com.sparrowwallet.sparrow.event;
/**
* Empty class to indicate the includeMempoolOutputs config setting has changed
*/
public class IncludeMempoolOutputsChangedEvent {
}
@@ -11,18 +11,18 @@ public class SpendUtxoEvent {
private final List<BlockTransactionHashIndex> utxos;
private final List<Payment> payments;
private final Long fee;
private final boolean includeMempoolInputs;
private final boolean includeSpentMempoolOutputs;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, false);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeMempoolInputs) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeSpentMempoolOutputs) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.fee = fee;
this.includeMempoolInputs = includeMempoolInputs;
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
}
public Wallet getWallet() {
@@ -41,7 +41,7 @@ public class SpendUtxoEvent {
return fee;
}
public boolean isIncludeMempoolInputs() {
return includeMempoolInputs;
public boolean isIncludeSpentMempoolOutputs() {
return includeSpentMempoolOutputs;
}
}
@@ -48,12 +48,14 @@ public class FontAwesome5 extends GlyphFont {
SATELLITE_DISH('\uf7c0'),
SD_CARD('\uf7c2'),
SEARCH('\uf002'),
SIGN_OUT_ALT('\uf2f5'),
SQUARE('\uf0c8'),
TIMES_CIRCLE('\uf057'),
TOGGLE_OFF('\uf204'),
TOGGLE_ON('\uf205'),
TOOLS('\uf7d9'),
UNDO('\uf0e2'),
USER_FRIENDS('\uf500'),
WALLET('\uf555');
private final char ch;
@@ -0,0 +1,36 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.util.List;
public class Bip32 implements KeystoreXprvImport {
@Override
public String getName() {
return "Master Private Key (BIP32)";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SEED;
}
@Override
public String getKeystoreImportDescription() {
return "Import an extended master private key (BIP 32 xprv)";
}
@Override
public Keystore getKeystore(List<ChildNumber> derivation, ExtendedKey xprv) throws ImportException {
try {
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(xprv.getKey().getPrivKeyBytes(), xprv.getKey().getChainCode());
return Keystore.fromMasterPrivateExtendedKey(masterPrivateExtendedKey, derivation);
} catch(Exception e) {
throw new ImportException(e);
}
}
}
@@ -11,8 +11,11 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Currency;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
@@ -28,7 +31,7 @@ public class Config {
private boolean loadRecentWallets = true;
private boolean validateDerivationPaths = true;
private boolean groupByAddress = true;
private boolean includeMempoolChange = true;
private boolean includeMempoolOutputs = true;
private boolean notifyNewTransactions = true;
private boolean checkNewVersions = true;
private Theme theme;
@@ -183,12 +186,12 @@ public class Config {
flush();
}
public boolean isIncludeMempoolChange() {
return includeMempoolChange;
public boolean isIncludeMempoolOutputs() {
return includeMempoolOutputs;
}
public void setIncludeMempoolChange(boolean includeMempoolChange) {
this.includeMempoolChange = includeMempoolChange;
public void setIncludeMempoolOutputs(boolean includeMempoolOutputs) {
this.includeMempoolOutputs = includeMempoolOutputs;
flush();
}
@@ -333,6 +336,11 @@ public class Config {
flush();
}
public void changePublicServer() {
List<String> otherServers = Arrays.stream(PublicElectrumServer.values()).map(PublicElectrumServer::getUrl).filter(url -> !url.equals(getPublicElectrumServer())).collect(Collectors.toList());
setPublicElectrumServer(otherServers.get(new Random().nextInt(otherServers.size())));
}
public String getCoreServer() {
return coreServer;
}
@@ -41,6 +41,10 @@ public class Device {
return needsPinSent;
}
public Boolean isNeedsPinSent() {
return needsPinSent != null && needsPinSent;
}
public void setNeedsPinSent(Boolean needsPinSent) {
this.needsPinSent = needsPinSent;
}
@@ -49,6 +53,10 @@ public class Device {
return needsPassphraseSent;
}
public Boolean isNeedsPassphraseSent() {
return needsPassphraseSent != null && needsPassphraseSent;
}
public void setNeedsPassphraseSent(Boolean needsPassphraseSent) {
this.needsPassphraseSent = needsPassphraseSent;
}
@@ -10,9 +10,7 @@ import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -233,6 +231,22 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
}
}
for(BlockTransaction blkTx : ew.transactions.values()) {
if(blkTx.getLabel() == null) {
Transaction tx = blkTx.getTransaction();
for(TransactionOutput txOutput : tx.getOutputs()) {
try {
Address[] addresses = txOutput.getScript().getToAddresses();
if(Arrays.asList(addresses).contains(address)) {
blkTx.setLabel(ew.labels.get(key));
}
} catch(NonStandardScriptException ex) {
//ignore
}
}
}
}
} catch(Exception ex) {
//not an address
}
@@ -314,11 +328,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
ek.xpub = keystore.getExtendedPublicKey().toString(xpubHeader);
ek.xprv = keystore.getExtendedPrivateKey().toString(xprvHeader);
ek.pw_hash_version = 1;
if(keystore.getSeed().getType() == DeterministicSeed.Type.ELECTRUM) {
if(keystore.getSeed() == null || keystore.getSeed().getType() == DeterministicSeed.Type.BIP39) {
ew.seed_type = "bip39";
} else if(keystore.getSeed().getType() == DeterministicSeed.Type.ELECTRUM) {
ek.seed = keystore.getSeed().getMnemonicString().asString();
ek.passphrase = keystore.getSeed().getPassphrase() == null ? null : keystore.getSeed().getPassphrase().asString();
} else if(keystore.getSeed().getType() == DeterministicSeed.Type.BIP39) {
ew.seed_type = "bip39";
}
ew.use_encryption = false;
} else if(keystore.getSource() == KeystoreSource.SW_WATCH) {
@@ -222,7 +222,9 @@ public class Hwi {
private synchronized File getHwiExecutable(Command command) {
File hwiExecutable = Config.get().getHwi();
if(hwiExecutable != null && hwiExecutable.exists()) {
if(command.isTestFirst() && (!hwiExecutable.getAbsolutePath().contains(VERSION_PREFIX) || !testHwi(hwiExecutable))) {
String tmpDir = System.getProperty("java.io.tmpdir");
String hwiPath = hwiExecutable.getAbsolutePath();
if(command.isTestFirst() && hwiPath.startsWith(tmpDir) && (!hwiPath.contains(VERSION_PREFIX) || !testHwi(hwiExecutable))) {
if(Platform.getCurrent() == Platform.OSX) {
deleteDirectory(hwiExecutable.getParentFile());
} else {
@@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.crypto.ChildNumber;
import com.sparrowwallet.drongo.wallet.Keystore;
import java.util.List;
public interface KeystoreXprvImport extends KeystoreImport {
Keystore getKeystore(List<ChildNumber> derivation, ExtendedKey xprv) throws ImportException;
}
@@ -527,7 +527,7 @@ public class Storage {
@Override
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore);
if(keystore.hasSeed()) {
if(keystore.hasPrivateKey()) {
jsonObject.remove("extendedPublicKey");
jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint");
}
@@ -45,7 +45,8 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(watchOnlyButtonType, cancelButtonType);
dialogPane.setPrefWidth(650);
dialogPane.setPrefHeight(620);
dialogPane.setPrefHeight(690);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? getWatchOnlyKeystore() : null);
} catch(IOException e) {
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.keystoreimport;
import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
import com.sparrowwallet.sparrow.control.MnemonicKeystoreImportPane;
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
import com.sparrowwallet.sparrow.control.XprvKeystoreImportPane;
import com.sparrowwallet.sparrow.io.*;
import javafx.fxml.FXML;
import javafx.scene.control.Accordion;
@@ -14,7 +15,7 @@ public class SwController extends KeystoreImportDetailController {
private Accordion importAccordion;
public void initializeView() {
List<KeystoreImport> importers = List.of(new Bip39(), new Electrum());
List<KeystoreImport> importers = List.of(new Bip39(), new Electrum(), new Bip32());
for(KeystoreImport importer : importers) {
TitledDescriptionPane importPane = null;
@@ -23,6 +24,8 @@ public class SwController extends KeystoreImportDetailController {
importPane = new FileKeystoreImportPane(getMasterController().getWallet(), (KeystoreFileImport)importer);
} else if(importer instanceof KeystoreMnemonicImport) {
importPane = new MnemonicKeystoreImportPane(getMasterController().getWallet(), (KeystoreMnemonicImport)importer);
} else if(importer instanceof KeystoreXprvImport) {
importPane = new XprvKeystoreImportPane(getMasterController().getWallet(), (KeystoreXprvImport)importer);
} else {
throw new IllegalArgumentException("Could not create ImportPane for importer of type " + importer.getClass());
}
@@ -58,7 +58,7 @@ public class Bwt {
private void start(Collection<Wallet> wallets, CallbackNotifier callback) {
List<Wallet> validWallets = wallets.stream().filter(Wallet::isValid).collect(Collectors.toList());
List<String> outputDescriptors = new ArrayList<>();
Set<String> outputDescriptors = new LinkedHashSet<>();
for(Wallet wallet : validWallets) {
OutputDescriptor receiveOutputDescriptor = OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.RECEIVE);
outputDescriptors.add(receiveOutputDescriptor.toString(false, false));
@@ -89,7 +89,7 @@ public class Bwt {
* @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) {
private void start(Collection<String> outputDescriptors, Integer rescanSince, Boolean forceRescan, Integer gapLimit, CallbackNotifier callback) {
BwtConfig bwtConfig = new BwtConfig();
bwtConfig.network = Network.get() == Network.MAINNET ? "bitcoin" : Network.get().getName();
@@ -188,7 +188,7 @@ public class Bwt {
public Boolean createWalletIfMissing;
@SerializedName("descriptors")
public List<String> descriptors;
public Collection<String> descriptors;
@SerializedName("xpubs")
public String xpubs;
@@ -44,6 +44,10 @@ public class ElectrumServer {
private static final Map<String, Set<String>> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static String previousServerAddress;
private static Map<String, String> retrievedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static ElectrumServerRpc electrumServerRpc = new SimpleElectrumServerRpc();
private static String bwtElectrumServer;
@@ -84,6 +88,12 @@ public class ElectrumServer {
throw new ServerConfigException("Electrum server URL must start with " + Protocol.TCP.toUrlString() + " or " + Protocol.SSL.toUrlString());
}
//If changing server, don't rely on previous transaction history
if(!electrumServer.equals(previousServerAddress)) {
retrievedScriptHashes.clear();
}
previousServerAddress = electrumServer;
HostAndPort server = protocol.getServerHostAndPort(electrumServer);
if(Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) {
@@ -150,6 +160,11 @@ public class ElectrumServer {
}
}
public static void clearRetrievedScriptHashes(Wallet wallet) {
wallet.getNode(KeyPurpose.RECEIVE).getChildren().stream().map(node -> getScriptHash(wallet, node)).forEach(scriptHash -> retrievedScriptHashes.remove(scriptHash));
wallet.getNode(KeyPurpose.CHANGE).getChildren().stream().map(node -> getScriptHash(wallet, node)).forEach(scriptHash -> retrievedScriptHashes.remove(scriptHash));
}
public Map<WalletNode, Set<BlockTransactionHash>> getHistory(Wallet wallet) throws ServerException {
Map<WalletNode, Set<BlockTransactionHash>> receiveTransactionMap = new TreeMap<>();
getHistory(wallet, KeyPurpose.RECEIVE, receiveTransactionMap);
@@ -177,6 +192,15 @@ public class ElectrumServer {
Set<BlockTransactionHash> newReferences = nodeTransactionMap.values().stream().flatMap(Collection::stream).filter(ref -> !wallet.getTransactions().containsKey(ref.getHash())).collect(Collectors.toSet());
getReferencedTransactions(wallet, nodeTransactionMap);
//Subscribe and retrieve transaction history from child nodes if necessary to maintain gap limit
Set<KeyPurpose> keyPurposes = nodes.stream().map(WalletNode::getKeyPurpose).collect(Collectors.toUnmodifiableSet());
for(KeyPurpose keyPurpose : keyPurposes) {
WalletNode purposeNode = wallet.getNode(keyPurpose);
getHistoryToGapLimit(wallet, nodeTransactionMap, purposeNode);
}
log.debug("Fetched nodes history for: " + nodeTransactionMap.keySet());
if(!newReferences.isEmpty()) {
//Look for additional nodes to fetch history for by considering the inputs and outputs of new transactions found
log.debug(wallet.getName() + " found new transactions: " + newReferences);
@@ -216,13 +240,22 @@ public class ElectrumServer {
public void getHistory(Wallet wallet, KeyPurpose keyPurpose, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) throws ServerException {
WalletNode purposeNode = wallet.getNode(keyPurpose);
//Subscribe to all existing address WalletNodes and add them to nodeTransactionMap as keys to empty sets if they have history
//Subscribe to all existing address WalletNodes and add them to nodeTransactionMap as keys to empty sets if they have history that needs to be fetched
subscribeWalletNodes(wallet, purposeNode.getChildren(), nodeTransactionMap, 0);
//All WalletNode keys in nodeTransactionMap need to have their history fetched (nodes without history will not be keys in the map yet)
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, 0);
//Fetch all referenced transaction to wallet transactions map. We do this now even though it is done again later to get it done before too many script hashes are subscribed
getReferencedTransactions(wallet, nodeTransactionMap);
//Increase child nodes if necessary to maintain gap limit, and ensure they are subscribed and history is fetched
getHistoryToGapLimit(wallet, nodeTransactionMap, purposeNode);
log.debug("Fetched history for: " + nodeTransactionMap.keySet());
//Set the remaining WalletNode keys in nodeTransactionMap to empty sets to indicate no history (if no script hash history has already been retrieved in a previous call)
purposeNode.getChildren().stream().filter(node -> !nodeTransactionMap.containsKey(node) && retrievedScriptHashes.get(getScriptHash(wallet, node)) == null).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
}
private void getHistoryToGapLimit(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, WalletNode purposeNode) throws ServerException {
//Because node children are added sequentially in WalletNode.fillToIndex, we can simply look at the number of children to determine the highest filled index
int historySize = purposeNode.getChildren().size();
//The gap limit size takes the highest used index in the retrieved history and adds the gap limit (plus one to be comparable to the number of children since index is zero based)
@@ -235,9 +268,6 @@ public class ElectrumServer {
historySize = purposeNode.getChildren().size();
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
}
//Set the remaining WalletNode keys in nodeTransactionMap to empty sets to indicate no history
purposeNode.getChildren().stream().filter(node -> !nodeTransactionMap.containsKey(node)).forEach(node -> nodeTransactionMap.put(node, Collections.emptySet()));
}
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
@@ -319,9 +349,12 @@ public class ElectrumServer {
if(node != null && node.getIndex() >= startIndex) {
String scriptHash = getScriptHash(wallet, node);
if(getSubscribedScriptHashStatus(scriptHash) != null) {
//Already subscribed, but still need to fetch history from a used node
nodeTransactionMap.put(node, new TreeSet<>());
String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
if(subscribedStatus != null) {
//Already subscribed, but still need to fetch history from a used node if not previously fetched
if(!subscribedStatus.equals(retrievedScriptHashes.get(scriptHash))) {
nodeTransactionMap.put(node, new TreeSet<>());
}
} else if(!subscribedScriptHashes.containsKey(scriptHash) && scriptHashes.add(scriptHash)) {
//Unique script hash we are not yet subscribed to
pathScriptHashes.put(node.getDerivationPath(), scriptHash);
@@ -329,6 +362,8 @@ public class ElectrumServer {
}
}
log.debug("Subscribe to: " + pathScriptHashes.keySet());
if(pathScriptHashes.isEmpty()) {
return;
}
@@ -343,8 +378,8 @@ public class ElectrumServer {
WalletNode node = optionalNode.get();
String scriptHash = getScriptHash(wallet, node);
//Check if there is history for this script hash
if(status != null) {
//Check if there is history for this script hash, and if the history has changed since last fetched
if(status != null && !status.equals(retrievedScriptHashes.get(scriptHash))) {
//Set the value for this node to be an empty set to mark it as requiring a get_history RPC call for this wallet
nodeTransactionMap.put(node, new TreeSet<>());
}
@@ -1032,11 +1067,22 @@ public class ElectrumServer {
protected Boolean call() throws ServerException {
walletSynchronizeLocks.putIfAbsent(wallet, new Object());
synchronized(walletSynchronizeLocks.get(wallet)) {
ElectrumServer electrumServer = new ElectrumServer();
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
return true;
if(isConnected()) {
ElectrumServer electrumServer = new ElectrumServer();
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
//Add all of the script hashes we have now fetched the history for so we don't need to fetch again until the script hash status changes
for(WalletNode node : nodeTransactionMap.keySet()) {
String scriptHash = getScriptHash(wallet, node);
retrievedScriptHashes.put(scriptHash, getSubscribedScriptHashStatus(scriptHash));
}
return true;
}
return false;
}
}
};
@@ -19,7 +19,7 @@ import java.util.concurrent.atomic.AtomicLong;
public class SimpleElectrumServerRpc implements ElectrumServerRpc {
private static final Logger log = LoggerFactory.getLogger(SimpleElectrumServerRpc.class);
private static final int MAX_TARGET_BLOCKS = 25;
private static final int MAX_RETRIES = 10;
private static final int MAX_RETRIES = 5;
private static final int RETRY_DELAY = 1;
private final AtomicLong idCounter = new AtomicLong();
@@ -13,6 +13,7 @@ import javax.net.SocketFactory;
import javax.net.ssl.SSLHandshakeException;
import java.io.*;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@@ -24,6 +25,7 @@ public class TcpTransport implements Transport, Closeable {
public static final int DEFAULT_PORT = 50001;
private static final int[] READ_TIMEOUT_SECS = {3, 8, 16, 34};
public static final int SOCKET_READ_TIMEOUT_MILLIS = 5000;
protected final HostAndPort server;
protected final SocketFactory socketFactory;
@@ -107,8 +109,9 @@ public class TcpTransport implements Transport, Closeable {
try {
readingCondition.await();
} catch(InterruptedException e) {
//Restore interrupt status and continue
//Restore interrupt status and break
Thread.currentThread().interrupt();
break;
}
}
@@ -180,7 +183,7 @@ public class TcpTransport implements Transport, Closeable {
}
protected String readInputStream(BufferedReader in) throws IOException {
String response = in.readLine();
String response = readLine(in);
if(response == null) {
throw new IOException("Could not connect to server at " + Config.get().getServerAddress());
@@ -189,9 +192,22 @@ public class TcpTransport implements Transport, Closeable {
return response;
}
private String readLine(BufferedReader in) throws IOException {
while(!socket.isClosed()) {
try {
return in.readLine();
} catch(SocketTimeoutException e) {
//ignore and continue
}
}
return null;
}
public void connect() throws ServerException {
try {
socket = createSocket();
socket.setSoTimeout(SOCKET_READ_TIMEOUT_MILLIS);
running = true;
} catch(SSLHandshakeException e) {
throw new TlsServerException(server, e);
@@ -215,7 +231,6 @@ public class TcpTransport implements Transport, Closeable {
@Override
public void close() throws IOException {
if(socket != null) {
running = false;
socket.close();
}
}
@@ -43,7 +43,7 @@ public class GeneralPreferencesController extends PreferencesDetailController {
private UnlabeledToggleSwitch groupByAddress;
@FXML
private UnlabeledToggleSwitch includeMempoolChange;
private UnlabeledToggleSwitch includeMempoolOutputs;
@FXML
private UnlabeledToggleSwitch notifyNewTransactions;
@@ -113,12 +113,13 @@ public class GeneralPreferencesController extends PreferencesDetailController {
});
groupByAddress.setSelected(config.isGroupByAddress());
includeMempoolChange.setSelected(config.isIncludeMempoolChange());
includeMempoolOutputs.setSelected(config.isIncludeMempoolOutputs());
groupByAddress.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
config.setGroupByAddress(newValue);
});
includeMempoolChange.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
config.setIncludeMempoolChange(newValue);
includeMempoolOutputs.selectedProperty().addListener((observableValue, oldValue, newValue) -> {
config.setIncludeMempoolOutputs(newValue);
EventManager.get().post(new IncludeMempoolOutputsChangedEvent());
});
notifyNewTransactions.setSelected(config.isNotifyNewTransactions());
@@ -48,6 +48,7 @@ public class PreferencesDialog extends Dialog<Boolean> {
dialogPane.setPrefWidth(750);
dialogPane.setPrefHeight(630);
AppServices.moveToActiveWindowScreen(this);
preferencesController.reconnectOnClosingProperty().set(AppServices.isConnecting() || AppServices.isConnected());
setOnCloseRequest(event -> {
@@ -277,6 +277,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
new FileChooser.ExtensionFilter("CRT", "*.crt")
);
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
electrumCertificate.setText(file.getAbsolutePath());
@@ -9,8 +9,7 @@ import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
@@ -490,7 +489,7 @@ public class HeadersController extends TransactionFormController implements Init
private void updateFee(Long feeAmt) {
fee.setValue(feeAmt);
double feeRateAmt = feeAmt.doubleValue() / headersForm.getTransaction().getVirtualSize();
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vByte");
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB" + (headersForm.isTransactionFinalized() ? "" : " (non-final)"));
}
private void updateBlockchainForm(BlockTransaction blockTransaction, Integer currentHeight) {
@@ -646,12 +645,9 @@ public class HeadersController extends TransactionFormController implements Init
//TODO: Remove once Cobo Vault has upgraded to UR2.0
boolean addLegacyEncodingOption = headersForm.getSigningWallet().getKeystores().stream().anyMatch(keystore -> keystore.getWalletModel().equals(WalletModel.COBO_VAULT) || keystore.getWalletModel().equals(WalletModel.SPARROW));
try {
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(RegistryType.CRYPTO_PSBT.toString(), headersForm.getPsbt().serialize(), addLegacyEncodingOption);
qrDisplayDialog.show();
} catch(UR.URException e) {
log.error("Error creating PSBT UR", e);
}
CryptoPSBT cryptoPSBT = new CryptoPSBT(headersForm.getPsbt().serialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), addLegacyEncodingOption);
qrDisplayDialog.show();
}
public void scanPSBT(ActionEvent event) {
@@ -674,6 +670,7 @@ public class HeadersController extends TransactionFormController implements Init
fileChooser.setInitialFileName(headersForm.getName() + ".psbt");
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
if(!file.getName().toLowerCase().endsWith(".psbt")) {
@@ -702,7 +699,7 @@ public class HeadersController extends TransactionFormController implements Init
}
private void signSoftwareKeystores() {
if(headersForm.getSigningWallet().getKeystores().stream().noneMatch(Keystore::hasSeed)) {
if(headersForm.getSigningWallet().getKeystores().stream().noneMatch(Keystore::hasPrivateKey)) {
return;
}
@@ -819,6 +816,7 @@ public class HeadersController extends TransactionFormController implements Init
transactionMempoolService = new ElectrumServer.TransactionMempoolService(headersForm.getSigningWallet(), headersForm.getTransaction().getTxId(), headersForm.getSigningWalletNodes());
transactionMempoolService.setDelay(Duration.seconds(3));
transactionMempoolService.setPeriod(Duration.seconds(10));
transactionMempoolService.setRestartOnFailure(false);
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
Set<String> scriptHashes = transactionMempoolService.getValue();
if(!scriptHashes.isEmpty()) {
@@ -833,6 +831,13 @@ public class HeadersController extends TransactionFormController implements Init
broadcastButton.setDisable(false);
}
});
transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> {
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 indicate it had entered the mempool. It is safe to try broadcasting again.");
broadcastButton.setDisable(false);
});
transactionMempoolService.start();
} else {
Sha256Hash txid = headersForm.getTransaction().getTxId();
@@ -876,6 +881,7 @@ public class HeadersController extends TransactionFormController implements Init
fileChooser.setInitialFileName(headersForm.getName().replace(".psbt", "") + ".txn");
}
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try {
@@ -937,7 +943,7 @@ 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) {
} else if(headersForm.getPsbt() == null && headersForm.getBlockTransaction() == null) {
boolean isSigned = true;
ObservableMap<TransactionSignature, Keystore> signatureKeystoreMap = FXCollections.observableMap(new LinkedHashMap<>());
for(TransactionInput txInput : headersForm.getTransaction().getInputs()) {
@@ -531,6 +531,7 @@ public class TransactionController implements Initializable {
if(event.getPsbt().equals(getPSBT())) {
txhex.setTransaction(getTransaction());
highlightTxHex();
txtree.refresh();
}
}
@@ -117,6 +117,7 @@ public class AddressesController extends WalletFormController implements Initial
WalletNode purposeNode = copy.getNode(keyPurpose);
purposeNode.fillToIndex(Math.max(purposeNode.getChildren().size(), DEFAULT_EXPORT_ADDRESSES_LENGTH));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
@@ -34,6 +34,7 @@ public class AdvancedDialog extends Dialog<Boolean> {
dialogPane.setPrefWidth(400);
dialogPane.setPrefHeight(300);
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == passwordButtonType);
}
@@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.DateLabel;
import com.sparrowwallet.sparrow.event.WalletEntryLabelChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import java.util.Collections;
import java.util.List;
@@ -58,7 +59,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
}
public boolean isSpendable() {
return !isSpent() && (hashIndex.getHeight() > 0 || getWallet().allInputsFromWallet(hashIndex.getHash())) && (hashIndex.getStatus() == null || hashIndex.getStatus() != Status.FROZEN);
return !isSpent() && (hashIndex.getHeight() > 0 || Config.get().isIncludeMempoolOutputs()) && (hashIndex.getStatus() == null || hashIndex.getStatus() != Status.FROZEN);
}
@Override
@@ -7,10 +7,7 @@ import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.QRDisplayDialog;
import com.sparrowwallet.sparrow.control.QRScanDialog;
import com.sparrowwallet.sparrow.control.SeedDisplayDialog;
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -57,6 +54,9 @@ public class KeystoreController extends WalletFormController implements Initiali
@FXML
private Button viewSeedButton;
@FXML
private Button viewKeyButton;
@FXML
private Button importButton;
@@ -106,6 +106,7 @@ public class KeystoreController extends WalletFormController implements Initiali
}
viewSeedButton.managedProperty().bind(viewSeedButton.visibleProperty());
viewKeyButton.managedProperty().bind(viewKeyButton.visibleProperty());
scanXpubQR.managedProperty().bind(scanXpubQR.visibleProperty());
displayXpubQR.managedProperty().bind(displayXpubQR.visibleProperty());
displayXpubQR.visibleProperty().bind(scanXpubQR.visibleProperty().not());
@@ -246,7 +247,8 @@ public class KeystoreController extends WalletFormController implements Initiali
private void updateType() {
type.setText(getTypeLabel(keystore));
type.setGraphic(getTypeIcon(keystore));
viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED);
viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed());
viewKeyButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasMasterPrivateExtendedKey());
importButton.setText(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import..." : "Replace...");
importButton.setTooltip(new Tooltip(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import a keystore from an external source" : "Replace this keystore with another source"));
@@ -317,6 +319,7 @@ public class KeystoreController extends WalletFormController implements Initiali
keystore.setLabel(importedKeystore.getLabel());
keystore.setKeyDerivation(importedKeystore.getKeyDerivation());
keystore.setExtendedPublicKey(importedKeystore.getExtendedPublicKey());
keystore.setMasterPrivateExtendedKey(importedKeystore.getMasterPrivateExtendedKey());
keystore.setSeed(importedKeystore.getSeed());
updateType();
@@ -333,7 +336,7 @@ public class KeystoreController extends WalletFormController implements Initiali
}
}
public void showSeed(ActionEvent event) {
public void showPrivate(ActionEvent event) {
int keystoreIndex = getWalletForm().getWallet().getKeystores().indexOf(keystore);
Wallet copy = getWalletForm().getWallet().copy();
@@ -345,7 +348,7 @@ public class KeystoreController extends WalletFormController implements Initiali
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(getWalletForm().getWalletFile(), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
showSeed(decryptedWallet.getKeystores().get(keystoreIndex));
showPrivate(decryptedWallet.getKeystores().get(keystoreIndex));
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(getWalletForm().getWalletFile(), TimedEvent.Action.END, "Failed"));
@@ -355,13 +358,18 @@ public class KeystoreController extends WalletFormController implements Initiali
decryptWalletService.start();
}
} else {
showSeed(keystore);
showPrivate(keystore);
}
}
private void showSeed(Keystore keystore) {
SeedDisplayDialog dlg = new SeedDisplayDialog(keystore);
dlg.showAndWait();
private void showPrivate(Keystore keystore) {
if(keystore.hasSeed()) {
SeedDisplayDialog dlg = new SeedDisplayDialog(keystore);
dlg.showAndWait();
} else if(keystore.hasMasterPrivateExtendedKey()) {
MasterKeyDisplayDialog dlg = new MasterKeyDisplayDialog(keystore);
dlg.showAndWait();
}
}
public void scanXpubQR(ActionEvent event) {
@@ -14,10 +14,7 @@ import com.sparrowwallet.drongo.wallet.UtxoSelector;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.CoinTextFormatter;
import com.sparrowwallet.sparrow.control.CopyableTextField;
import com.sparrowwallet.sparrow.control.FiatLabel;
import com.sparrowwallet.sparrow.control.QRScanDialog;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
@@ -59,6 +56,9 @@ public class PaymentController extends WalletFormController implements Initializ
@FXML
private FiatLabel fiatAmount;
@FXML
private Label amountStatus;
@FXML
private ToggleButton maxButton;
@@ -110,7 +110,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
revalidate(amount, amountListener);
maxButton.setDisable(!isValidRecipientAddress());
maxButton.setDisable(!isValidAddressAndLabel());
sendController.updateTransaction();
if(validationSupport != null) {
@@ -119,6 +119,7 @@ public class PaymentController extends WalletFormController implements Initializ
});
label.textProperty().addListener((observable, oldValue, newValue) -> {
maxButton.setDisable(!isValidAddressAndLabel());
sendController.getCreateButton().setDisable(sendController.getWalletTransaction() == null || newValue == null || newValue.isEmpty() || sendController.isInsufficientFeeRate());
sendController.updateTransaction();
});
@@ -136,10 +137,15 @@ public class PaymentController extends WalletFormController implements Initializ
}
});
maxButton.setDisable(!isValidRecipientAddress());
maxButton.setDisable(!isValidAddressAndLabel());
sendController.utxoLabelSelectionProperty().addListener((observable, oldValue, newValue) -> {
maxButton.setText("Max" + newValue);
});
amountStatus.managedProperty().bind(amountStatus.visibleProperty());
amountStatus.setVisible(sendController.isInsufficientInputs());
sendController.insufficientInputsProperty().addListener((observable, oldValue, newValue) -> {
amountStatus.setVisible(newValue);
});
Optional<Tab> firstTab = sendController.getPaymentTabs().getTabs().stream().findFirst();
if(firstTab.isPresent()) {
@@ -175,6 +181,10 @@ public class PaymentController extends WalletFormController implements Initializ
}
}
private boolean isValidAddressAndLabel() {
return isValidRecipientAddress() && !label.getText().isEmpty();
}
private Address getRecipientAddress() throws InvalidAddressException {
return Address.fromString(address.getText());
}
@@ -209,7 +219,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript());
return address.getScriptType().getDustThreshold(txOutput, sendController.getFeeRate());
return address.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
}
private void setFiatAmount(CurrencyRate currencyRate, Long amount) {
@@ -225,8 +235,10 @@ public class PaymentController extends WalletFormController implements Initializ
private void revalidate(TextField field, ChangeListener<String> listener) {
field.textProperty().removeListener(listener);
String amt = field.getText();
int caret = field.getCaretPosition();
field.setText(amt + "0");
field.setText(amt);
field.positionCaret(caret);
field.textProperty().addListener(listener);
}
@@ -279,6 +291,8 @@ public class PaymentController extends WalletFormController implements Initializ
fiatAmount.setText("");
setSendMax(false);
amountStatus.setVisible(false);
}
public void setMaxInput(ActionEvent event) {
@@ -7,11 +7,9 @@ import com.google.zxing.client.j2se.MatrixToImageConfig;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
@@ -23,6 +21,7 @@ import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
@@ -30,6 +29,7 @@ import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import org.controlsfx.glyphfont.Glyph;
import org.fxmisc.richtext.CodeArea;
import org.slf4j.Logger;
@@ -88,6 +88,15 @@ public class ReceiveController extends WalletFormController implements Initializ
displayAddress.managedProperty().bind(displayAddress.visibleProperty());
displayAddress.setVisible(false);
qrCode.setOnMouseClicked(event -> {
if(currentEntry != null) {
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(currentEntry.getAddress().toString());
qrDisplayDialog.showAndWait();
}
});
Platform.runLater(this::refreshAddress);
}
public void setNodeEntry(NodeEntry nodeEntry) {
@@ -125,14 +134,22 @@ public class ReceiveController extends WalletFormController implements Initializ
if(AppServices.isConnected() && currentOutputs.isEmpty()) {
lastUsed.setText("Never");
lastUsed.setGraphic(getUnusedGlyph());
address.getStyleClass().remove("error");
address.setDisable(false);
} else if(!currentOutputs.isEmpty()) {
long count = currentOutputs.size();
BlockTransactionHashIndex lastUsedReference = currentOutputs.stream().skip(count - 1).findFirst().get();
lastUsed.setText(lastUsedReference.getHeight() <= 0 ? "Unconfirmed Transaction" : DATE_FORMAT.format(lastUsedReference.getDate()));
lastUsed.setGraphic(getWarningGlyph());
if(!address.getStyleClass().contains("error")) {
address.getStyleClass().add("error");
address.setDisable(true);
}
} else {
lastUsed.setText("Unknown");
lastUsed.setGraphic(null);
lastUsed.setGraphic(getUnknownGlyph());
address.getStyleClass().remove("error");
address.setDisable(false);
}
}
@@ -143,7 +160,7 @@ public class ReceiveController extends WalletFormController implements Initializ
List<Device> addressDevices = devices.stream().filter(device -> walletFingerprints.contains(device.getFingerprint())).collect(Collectors.toList());
if(addressDevices.isEmpty()) {
addressDevices = devices.stream().filter(device -> device.getNeedsPinSent() || device.getNeedsPassphraseSent()).collect(Collectors.toList());
addressDevices = devices.stream().filter(device -> device.isNeedsPinSent() || device.isNeedsPassphraseSent()).collect(Collectors.toList());
}
if(!addressDevices.isEmpty()) {
@@ -181,6 +198,10 @@ public class ReceiveController extends WalletFormController implements Initializ
}
public void getNewAddress(ActionEvent event) {
refreshAddress();
}
public void refreshAddress() {
NodeEntry freshEntry = getWalletForm().getFreshNodeEntry(KeyPurpose.RECEIVE, currentEntry);
setNodeEntry(freshEntry);
}
@@ -193,7 +214,7 @@ public class ReceiveController extends WalletFormController implements Initializ
List<Device> possibleDevices = (List<Device>)displayAddress.getUserData();
if(possibleDevices != null && !possibleDevices.isEmpty()) {
if(possibleDevices.size() > 1 || possibleDevices.get(0).getNeedsPinSent() || possibleDevices.get(0).getNeedsPassphraseSent()) {
if(possibleDevices.size() > 1 || possibleDevices.get(0).isNeedsPinSent() || possibleDevices.get(0).isNeedsPassphraseSent()) {
DeviceAddressDialog dlg = new DeviceAddressDialog(wallet, addressDescriptor);
dlg.showAndWait();
} else {
@@ -244,6 +265,12 @@ public class ReceiveController extends WalletFormController implements Initializ
return duplicateGlyph;
}
public static Glyph getUnknownGlyph() {
Glyph duplicateGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QUESTION_CIRCLE);
duplicateGlyph.setFontSize(12);
return duplicateGlyph;
}
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
displayAddress.setUserData(null);
@@ -259,7 +286,8 @@ public class ReceiveController extends WalletFormController implements Initializ
@Subscribe
public void walletNodesChanged(WalletNodesChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
clear();
currentEntry = null;
refreshAddress();
}
}
@@ -267,7 +295,7 @@ public class ReceiveController extends WalletFormController implements Initializ
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
if(currentEntry != null && event.getHistoryChangedNodes().contains(currentEntry.getNode())) {
updateLastUsed();
refreshAddress();
}
}
}
@@ -276,4 +304,14 @@ public class ReceiveController extends WalletFormController implements Initializ
public void usbDevicesFound(UsbDeviceEvent event) {
updateDisplayAddress(event.getDevices());
}
@Subscribe
public void connection(ConnectionEvent event) {
updateLastUsed();
}
@Subscribe
public void disconnection(DisconnectionEvent event) {
updateLastUsed();
}
}
@@ -13,15 +13,16 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import com.sparrowwallet.sparrow.net.*;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
@@ -29,6 +30,7 @@ import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
@@ -44,7 +46,6 @@ import java.net.URL;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
public class SendController extends WalletFormController implements Initializable {
@@ -82,6 +83,9 @@ public class SendController extends WalletFormController implements Initializabl
@FXML
private CopyableLabel feeRate;
@FXML
private Label cpfpFeeRate;
@FXML
private Label feeRatePriority;
@@ -128,7 +132,7 @@ public class SendController extends WalletFormController implements Initializabl
private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty("");
private final BooleanProperty includeMempoolInputsProperty = new SimpleBooleanProperty(false);
private final BooleanProperty includeSpentMempoolOutputsProperty = new SimpleBooleanProperty(false);
private final ChangeListener<String> feeListener = new ChangeListener<>() {
@Override
@@ -185,6 +189,8 @@ public class SendController extends WalletFormController implements Initializabl
private ValidationSupport validationSupport;
private WalletTransactionService walletTransactionService;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -282,13 +288,17 @@ public class SendController extends WalletFormController implements Initializabl
FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection();
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.MEMPOOL_SIZE : feeRatesSelection);
cpfpFeeRate.managedProperty().bind(cpfpFeeRate.visibleProperty());
cpfpFeeRate.setVisible(false);
setDefaultFeeRate();
updateFeeRateSelection(feeRatesSelection);
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle);
feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
Config.get().setFeeRatesSelection(newFeeRatesSelection);
EventManager.get().post(new FeeRatesSelectionChangedEvent(getWalletForm().getWallet(), newFeeRatesSelection));
if(newValue != null) {
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
Config.get().setFeeRatesSelection(newFeeRatesSelection);
EventManager.get().post(new FeeRatesSelectionChangedEvent(getWalletForm().getWallet(), newFeeRatesSelection));
};
});
fee.setTextFormatter(new CoinTextFormatter());
@@ -336,15 +346,12 @@ public class SendController extends WalletFormController implements Initializabl
if(userFeeSet.get()) {
setTargetBlocks(getTargetBlocks(feeRate));
setFeeRangeRate(feeRate);
if(walletTransaction.getFee() != getFeeValueSats()) {
setFeeValueSats(walletTransaction.getFee());
}
} else {
setFeeValueSats(walletTransaction.getFee());
}
setFeeRate(feeRate);
setEffectiveFeeRate(walletTransaction);
}
transactionDiagram.update(walletTransaction);
@@ -353,7 +360,7 @@ public class SendController extends WalletFormController implements Initializabl
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
if(oldScene == null && newScene != null) {
transactionDiagram.update(null);
transactionDiagram.update(walletTransactionProperty.get());
newScene.getWindow().heightProperty().addListener((observable1, oldValue, newValue) -> {
transactionDiagram.update(walletTransactionProperty.get());
});
@@ -471,28 +478,54 @@ public class SendController extends WalletFormController implements Initializabl
}
public void updateTransaction(List<Payment> transactionPayments) {
if(walletTransactionService != null && walletTransactionService.isRunning()) {
walletTransactionService.setIgnoreResult(true);
walletTransactionService.cancel();
}
try {
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
Wallet wallet = getWalletForm().getWallet();
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
double feeRate = getUserFeeRate();
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolChange = Config.get().isIncludeMempoolChange();
boolean includeMempoolInputs = includeMempoolInputsProperty.get();
WalletTransaction walletTransaction = wallet.createWalletTransaction(getUtxoSelectors(), getUtxoFilters(), payments, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolChange, includeMempoolInputs);
walletTransactionProperty.setValue(walletTransaction);
insufficientInputsProperty.set(false);
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
boolean includeSpentMempoolOutputs = includeSpentMempoolOutputsProperty.get();
return;
walletTransactionService = new WalletTransactionService(wallet, getUtxoSelectors(), getUtxoFilters(), payments, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
walletTransactionService.setOnSucceeded(event -> {
if(!walletTransactionService.isIgnoreResult()) {
walletTransactionProperty.setValue(walletTransactionService.getValue());
insufficientInputsProperty.set(false);
}
});
walletTransactionService.setOnFailed(event -> {
if(!walletTransactionService.isIgnoreResult()) {
transactionDiagram.clear();
walletTransactionProperty.setValue(null);
if(event.getSource().getException() instanceof InsufficientFundsException) {
insufficientInputsProperty.set(true);
}
}
});
final WalletTransactionService currentWalletTransactionService = walletTransactionService;
final KeyFrame delay = new KeyFrame(Duration.millis(200), e -> {
if(currentWalletTransactionService.isRunning()) {
transactionDiagram.update("Selecting UTXOs...");
createButton.setDisable(true);
}
});
final Timeline timeline = new Timeline(delay);
timeline.play();
walletTransactionService.start();
}
} catch(InvalidAddressException | IllegalStateException e) {
//ignore
} catch(InsufficientFundsException e) {
insufficientInputsProperty.set(true);
walletTransactionProperty.setValue(null);
}
walletTransactionProperty.setValue(null);
}
private List<UtxoSelector> getUtxoSelectors() throws InvalidAddressException {
@@ -501,12 +534,58 @@ public class SendController extends WalletFormController implements Initializabl
}
Wallet wallet = getWalletForm().getWallet();
long noInputsFee = wallet.getNoInputsFee(getPayments(), getFeeRate());
long costOfChange = wallet.getCostOfChange(getFeeRate(), getMinimumFeeRate());
long noInputsFee = wallet.getNoInputsFee(getPayments(), getUserFeeRate());
long costOfChange = wallet.getCostOfChange(getUserFeeRate(), getMinimumFeeRate());
return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee));
}
private static class WalletTransactionService extends Service<WalletTransaction> {
private final Wallet wallet;
private final List<UtxoSelector> utxoSelectors;
private final List<UtxoFilter> utxoFilters;
private final List<Payment> payments;
private final double feeRate;
private final double longTermFeeRate;
private final Long fee;
private final Integer currentBlockHeight;
private final boolean groupByAddress;
private final boolean includeMempoolOutputs;
private final boolean includeSpentMempoolOutputs;
private boolean ignoreResult;
public WalletTransactionService(Wallet wallet, List<UtxoSelector> utxoSelectors, List<UtxoFilter> utxoFilters, List<Payment> payments, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) {
this.wallet = wallet;
this.utxoSelectors = utxoSelectors;
this.utxoFilters = utxoFilters;
this.payments = payments;
this.feeRate = feeRate;
this.longTermFeeRate = longTermFeeRate;
this.fee = fee;
this.currentBlockHeight = currentBlockHeight;
this.groupByAddress = groupByAddress;
this.includeMempoolOutputs = includeMempoolOutputs;
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
}
@Override
protected Task<WalletTransaction> createTask() {
return new Task<>() {
protected WalletTransaction call() throws InsufficientFundsException {
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
}
};
}
public boolean isIgnoreResult() {
return ignoreResult;
}
public void setIgnoreResult(boolean ignoreResult) {
this.ignoreResult = ignoreResult;
}
}
private List<UtxoFilter> getUtxoFilters() {
UtxoFilter utxoFilter = utxoFilterProperty.get();
if(utxoFilter != null) {
@@ -612,6 +691,17 @@ public class SendController extends WalletFormController implements Initializabl
feeRange.valueProperty().addListener(feeRangeListener);
}
/**
* This method retrieves the fee rate used as input to constructing the transaction.
* Where the user has set a custom fee amount, using the slider fee rate can mean the UTXO selectors underestimate the UTXO effective values and fail to find a solution
* In this case, use a fee rate of 1 sat/VB for maximum flexibility
*
* @return the fee rate to use when constructing a transaction
*/
public Double getUserFeeRate() {
return (userFeeSet.get() ? Transaction.DEFAULT_MIN_RELAY_FEE : getFeeRate());
}
public Double getFeeRate() {
if(targetBlocksField.isVisible()) {
return getTargetBlocksFeeRates().get(getTargetBlocks());
@@ -635,10 +725,27 @@ public class SendController extends WalletFormController implements Initializabl
}
private void setFeeRate(Double feeRateAmt) {
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vByte");
feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vB");
setFeeRatePriority(feeRateAmt);
}
private void setEffectiveFeeRate(WalletTransaction walletTransaction) {
List<BlockTransaction> unconfirmedUtxoTxs = walletTransaction.getSelectedUtxos().keySet().stream().filter(ref -> ref.getHeight() <= 0)
.map(ref -> getWalletForm().getWallet().getTransactions().get(ref.getHash())).filter(Objects::nonNull).distinct().collect(Collectors.toList());
if(!unconfirmedUtxoTxs.isEmpty()) {
long utxoTxFee = unconfirmedUtxoTxs.stream().mapToLong(BlockTransaction::getFee).sum();
double utxoTxSize = unconfirmedUtxoTxs.stream().mapToDouble(blkTx -> blkTx.getTransaction().getVirtualSize()).sum();
long thisFee = walletTransaction.getFee();
double thisSize = walletTransaction.getTransaction().getVirtualSize();
double effectiveRate = (utxoTxFee + thisFee) / (utxoTxSize + thisSize);
Tooltip tooltip = new Tooltip("Child Pays For Parent\n" + String.format("%.2f", effectiveRate) + " sats/vB effective rate");
cpfpFeeRate.setTooltip(tooltip);
cpfpFeeRate.setVisible(true);
} else {
cpfpFeeRate.setVisible(false);
}
}
private void setFeeRatePriority(Double feeRateAmt) {
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
Integer targetBlocks = getTargetBlocks(feeRateAmt);
@@ -783,13 +890,14 @@ public class SendController extends WalletFormController implements Initializabl
fee.setText("");
fee.textProperty().addListener(feeListener);
cpfpFeeRate.setVisible(false);
fiatFeeAmount.setText("");
userFeeSet.set(false);
setDefaultFeeRate();
utxoSelectorProperty.setValue(null);
utxoFilterProperty.setValue(null);
includeMempoolInputsProperty.set(false);
includeSpentMempoolOutputsProperty.set(false);
walletTransactionProperty.setValue(null);
createdWalletTransactionProperty.set(null);
@@ -839,8 +947,10 @@ public class SendController extends WalletFormController implements Initializabl
private void revalidate(TextField field, ChangeListener<String> listener) {
field.textProperty().removeListener(listener);
String amt = field.getText();
int caret = field.getCaretPosition();
field.setText(amt + "0");
field.setText(amt);
field.positionCaret(caret);
field.textProperty().addListener(listener);
}
@@ -955,12 +1065,12 @@ public class SendController extends WalletFormController implements Initializabl
userFeeSet.set(true);
}
includeMempoolInputsProperty.set(event.isIncludeMempoolInputs());
includeSpentMempoolOutputsProperty.set(event.isIncludeSpentMempoolOutputs());
List<BlockTransactionHashIndex> utxos = event.getUtxos();
utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
utxoFilterProperty.set(null);
updateTransaction(event.getPayments() == null);
updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax));
}
}
@@ -1032,4 +1142,16 @@ public class SendController extends WalletFormController implements Initializabl
}
}
}
@Subscribe
public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) {
updateTransaction();
}
@Subscribe
public void newBlock(NewBlockEvent event) {
if(cpfpFeeRate.isVisible()) {
updateTransaction();
}
}
}
@@ -1,8 +1,7 @@
package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -11,6 +10,8 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
@@ -45,6 +46,12 @@ public class SettingsController extends WalletFormController implements Initiali
@FXML
private DescriptorArea descriptor;
@FXML
private Button scanDescriptorQR;
@FXML
private Button showDescriptorQR;
@FXML
private ComboBox<ScriptType> scriptType;
@@ -172,6 +179,11 @@ public class SettingsController extends WalletFormController implements Initiali
});
initializeDescriptorField(descriptor);
scanDescriptorQR.managedProperty().bind(scanDescriptorQR.visibleProperty());
scanDescriptorQR.prefHeightProperty().bind(descriptor.prefHeightProperty());
showDescriptorQR.managedProperty().bind(showDescriptorQR.visibleProperty());
showDescriptorQR.prefHeightProperty().bind(descriptor.prefHeightProperty());
showDescriptorQR.visibleProperty().bind(scanDescriptorQR.visibleProperty().not());
revert.setOnAction(event -> {
keystoreTabs.getTabs().removeAll(keystoreTabs.getTabs());
@@ -223,6 +235,7 @@ public class SettingsController extends WalletFormController implements Initiali
scriptType.getSelectionModel().select(walletForm.getWallet().getScriptType());
}
scanDescriptorQR.setVisible(!walletForm.getWallet().isValid());
export.setDisable(!walletForm.getWallet().isValid());
revert.setDisable(true);
apply.setDisable(true);
@@ -265,6 +278,14 @@ public class SettingsController extends WalletFormController implements Initiali
QRScanDialog.Result result = optionalResult.get();
if(result.outputDescriptor != null) {
setDescriptorText(result.outputDescriptor.toString());
} else if(result.wallets != null) {
for(Wallet wallet : result.wallets) {
if(scriptType.getValue().equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
setDescriptorText(outputDescriptor.toString());
break;
}
}
} else if(result.payload != null && !result.payload.isEmpty()) {
setDescriptorText(result.payload);
} else if(result.exception != null) {
@@ -273,6 +294,61 @@ public class SettingsController extends WalletFormController implements Initiali
}
}
public void showDescriptorQR(ActionEvent event) {
if(!walletForm.getWallet().isValid()) {
AppServices.showErrorDialog("Wallet Invalid", "Cannot show a descriptor for an invalid wallet.");
return;
}
List<ScriptExpression> scriptExpressions = getScriptExpressions(walletForm.getWallet().getScriptType());
CryptoOutput cryptoOutput;
if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE) {
cryptoOutput = new CryptoOutput(scriptExpressions, getCryptoHDKey(walletForm.getWallet().getKeystores().get(0)));
} else if(walletForm.getWallet().getPolicyType() == PolicyType.MULTI) {
List<CryptoHDKey> cryptoHDKeys = walletForm.getWallet().getKeystores().stream().map(this::getCryptoHDKey).collect(Collectors.toList());
MultiKey multiKey = new MultiKey(walletForm.getWallet().getDefaultPolicy().getNumSignaturesRequired(), null, cryptoHDKeys);
List<ScriptExpression> multiScriptExpressions = new ArrayList<>(scriptExpressions);
multiScriptExpressions.add(ScriptExpression.SORTED_MULTISIG);
cryptoOutput = new CryptoOutput(multiScriptExpressions, multiKey);
} else {
AppServices.showErrorDialog("Unsupported Wallet Policy", "Cannot show a descriptor for this wallet.");
return;
}
UR cryptoOutputUR = cryptoOutput.toUR();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoOutputUR);
qrDisplayDialog.showAndWait();
}
private List<ScriptExpression> getScriptExpressions(ScriptType scriptType) {
if(scriptType == ScriptType.P2PK) {
return List.of(ScriptExpression.PUBLIC_KEY);
} else if(scriptType == ScriptType.P2PKH) {
return List.of(ScriptExpression.PUBLIC_KEY_HASH);
} else if(scriptType == ScriptType.P2SH_P2WPKH) {
return List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_PUBLIC_KEY_HASH);
} else if(scriptType == ScriptType.P2WPKH) {
return List.of(ScriptExpression.WITNESS_PUBLIC_KEY_HASH);
} else if(scriptType == ScriptType.P2SH) {
return List.of(ScriptExpression.SCRIPT_HASH);
} else if(scriptType == ScriptType.P2SH_P2WSH) {
return List.of(ScriptExpression.SCRIPT_HASH, ScriptExpression.WITNESS_SCRIPT_HASH);
} else if(scriptType == ScriptType.P2WSH) {
return List.of(ScriptExpression.WITNESS_SCRIPT_HASH);
}
throw new IllegalArgumentException("Unknown script type of " + scriptType);
}
private CryptoHDKey getCryptoHDKey(Keystore keystore) {
ExtendedKey extendedKey = keystore.getExtendedPublicKey();
CryptoCoinInfo cryptoCoinInfo = new CryptoCoinInfo(CryptoCoinInfo.Type.BITCOIN.ordinal(), Network.get() == Network.MAINNET ? CryptoCoinInfo.Network.MAINNET.ordinal() : CryptoCoinInfo.Network.TESTNET.ordinal());
List<PathComponent> pathComponents = keystore.getKeyDerivation().getDerivation().stream().map(cNum -> new PathComponent(cNum.num(), cNum.isHardened())).collect(Collectors.toList());
CryptoKeypath cryptoKeypath = new CryptoKeypath(pathComponents, Utils.hexToBytes(keystore.getKeyDerivation().getMasterFingerprint()), pathComponents.size());
return new CryptoHDKey(false, extendedKey.getKey().getPubKey(), extendedKey.getKey().getChainCode(), cryptoCoinInfo, cryptoKeypath, null, extendedKey.getParentFingerprint());
}
public void editDescriptor(ActionEvent event) {
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet());
String outputDescriptorString = outputDescriptor.toString(walletForm.getWallet().isValid());
@@ -359,6 +435,7 @@ public class SettingsController extends WalletFormController implements Initiali
revert.setDisable(false);
apply.setDisable(!wallet.isValid());
export.setDisable(true);
scanDescriptorQR.setVisible(!wallet.isValid());
}
}
@@ -366,6 +443,7 @@ public class SettingsController extends WalletFormController implements Initiali
public void walletSettingsChanged(WalletSettingsChangedEvent event) {
if(event.getWalletFile().equals(walletForm.getWalletFile())) {
export.setDisable(!event.getWallet().isValid());
scanDescriptorQR.setVisible(!event.getWallet().isValid());
}
}
@@ -155,6 +155,16 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
@Override
public int compareTo(TransactionEntry other) {
int blockOrder = blockTransaction.compareBlockOrder(other.blockTransaction);
if(blockOrder != 0) {
return blockOrder;
}
int valueOrder = Long.compare(other.getValue(), getValue());
if(valueOrder != 0) {
return valueOrder;
}
return blockTransaction.compareTo(other.blockTransaction);
}
@@ -26,6 +26,7 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Locale;
import java.util.ResourceBundle;
@@ -107,6 +108,7 @@ public class TransactionsController extends WalletFormController implements Init
fileChooser.setTitle("Export Transactions as CSV");
fileChooser.setInitialFileName(getWalletForm().getWallet().getName() + ".csv");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
@@ -226,4 +228,9 @@ public class TransactionsController extends WalletFormController implements Init
transactionsTable.refresh();
}
}
@Subscribe
public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) {
walletHistoryChanged(new WalletHistoryChangedEvent(getWalletForm().getWallet(), getWalletForm().getStorage(), Collections.emptyList()));
}
}
@@ -160,4 +160,9 @@ public class UtxosController extends WalletFormController implements Initializab
utxosTable.refresh();
}
}
@Subscribe
public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) {
utxosTable.refresh();
}
}
@@ -100,11 +100,18 @@ public class WalletForm {
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 WalletHistoryFinishedEvent(wallet));
updateWallet(blockHeight, pastWallet, previousWallet);
if(historyService.getValue()) {
EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
updateWallet(blockHeight, pastWallet, previousWallet);
}
});
historyService.setOnFailed(workerStateEvent -> {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
if(AppServices.isConnected()) {
log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
} else {
log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException());
}
EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
});
@@ -311,6 +318,7 @@ public class WalletForm {
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {
if(tabData.getWalletForm() == this) {
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().unregister(this);
}
}
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.NewWalletTransactionsEvent;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.LongProperty;
import javafx.beans.property.LongPropertyBase;
import org.slf4j.Logger;
@@ -37,9 +38,11 @@ public class WalletTransactionsEntry extends Entry {
for(Entry entry : getChildren()) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(transactionEntry.getConfirmations() != 0) {
if(transactionEntry.getConfirmations() != 0 || transactionEntry.getValue() < 0 || Config.get().isIncludeMempoolOutputs()) {
balance += entry.getValue();
} else {
}
if(transactionEntry.getConfirmations() == 0) {
mempoolBalance += entry.getValue();
}
@@ -30,6 +30,8 @@
<Menu fx:id="savePSBT" mnemonicParsing="false" text="Save PSBT">
<MenuItem text="As Binary..." onAction="#savePSBTBinary" accelerator="Shortcut+S"/>
<MenuItem text="As Base64..." onAction="#savePSBTText"/>
<MenuItem text="As Binary (No Xpubs)..." onAction="#savePSBTBinaryNoXpubs"/>
<MenuItem text="As Base64 (No Xpubs)..." onAction="#savePSBTTextNoXpubs"/>
</Menu>
<SeparatorMenuItem />
<MenuItem mnemonicParsing="false" text="Import Wallet..." onAction="#importWallet"/>
@@ -100,6 +102,7 @@
<MenuItem mnemonicParsing="false" text="Show Introduction" onAction="#showIntroduction"/>
<MenuItem mnemonicParsing="false" text="Show Online Documentation" onAction="#showDocumentation"/>
<MenuItem mnemonicParsing="false" text="Show Log File" onAction="#showLogFile"/>
<MenuItem mnemonicParsing="false" text="Submit Bug Report" onAction="#submitBugReport"/>
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="About Sparrow" onAction="#showAbout"/>
</Menu>
</menus>
@@ -110,6 +110,10 @@
-fx-text-fill: #e06c75;
}
.root .coin-cell.negative-amount {
-fx-text-fill: #e09499;
}
.root .etched-raised-border {
-fx-border-color: #ffffff, #000000;
-fx-border-style: solid, solid;
@@ -15,6 +15,10 @@
-fx-pref-height: 25px;
}
.form .fieldset:horizontal .input-container {
-fx-alignment: center-left;
}
.form .input-container {
-fx-spacing: 2;
}
@@ -86,9 +86,9 @@
<UnlabeledToggleSwitch fx:id="groupByAddress" />
<HelpLabel helpText="Group UTXOs by address when sending to improve privacy by only sending from an address once."/>
</Field>
<Field text="Use mempool change:">
<UnlabeledToggleSwitch fx:id="includeMempoolChange" />
<HelpLabel helpText="Allow a wallet to spend UTXOs that are still in the mempool where all their inputs are from that wallet."/>
<Field text="Allow unconfirmed:">
<UnlabeledToggleSwitch fx:id="includeMempoolOutputs" />
<HelpLabel helpText="Allow a wallet to spend UTXOs that are still in the mempool."/>
</Field>
</Fieldset>
<Fieldset inputGrow="SOMETIMES" text="Notifications" styleClass="wideLabelFieldSet">
@@ -22,7 +22,7 @@
<Fieldset inputGrow="SOMETIMES" text="">
<Field text="Type:">
<Label fx:id="type" graphicTextGap="8"/>
<Button fx:id="viewSeedButton" text="View Seed..." graphicTextGap="5" onAction="#showSeed">
<Button fx:id="viewSeedButton" text="View Seed..." graphicTextGap="5" onAction="#showPrivate">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="KEY" />
</graphic>
@@ -30,6 +30,14 @@
<Tooltip text="View mnemonic seed words"/>
</tooltip>
</Button>
<Button fx:id="viewKeyButton" text="View Key..." graphicTextGap="5" onAction="#showPrivate">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="KEY" />
</graphic>
<tooltip>
<Tooltip text="View master private key"/>
</tooltip>
</Button>
<Pane HBox.hgrow="ALWAYS" />
<Button fx:id="importButton" text="Import..." graphicTextGap="5" onAction="#importKeystore">
<graphic>
@@ -31,10 +31,18 @@
<Form GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2">
<Fieldset inputGrow="ALWAYS">
<Field text="Pay to:">
<CopyableTextField fx:id="address" styleClass="address-text-field"/>
<CopyableTextField fx:id="address" styleClass="address-text-field">
<tooltip>
<Tooltip text="Address or bitcoin: URI"/>
</tooltip>
</CopyableTextField>
</Field>
<Field text="Label:">
<TextField fx:id="label" />
<TextField fx:id="label">
<tooltip>
<Tooltip text="Required to label the transaction (privately in this wallet)"/>
</tooltip>
</TextField>
</Field>
<Field text="Amount:">
<TextField fx:id="amount" styleClass="amount-field" />
@@ -48,13 +56,23 @@
</ComboBox>
<Label style="-fx-pref-width: 15" />
<FiatLabel fx:id="fiatAmount" />
<Region style="-fx-pref-width: 20" />
<Group managed="false" translateY="38">
<Label fx:id="amountStatus" text="Insufficient funds">
<tooltip>
<Tooltip text="Insufficient confirmed transaction value" />
</tooltip>
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="EXCLAMATION_CIRCLE" styleClass="failure" />
</graphic>
</Label>
</Group>
<Region style="-fx-pref-width: 5" />
<ToggleButton fx:id="maxButton" text="Max" onAction="#setMaxInput" />
</Field>
</Fieldset>
</Form>
<Form GridPane.columnIndex="2" GridPane.rowIndex="0">
<Fieldset inputGrow="ALWAYS" style="-fx-padding: 2 0 0 0">
<Fieldset inputGrow="ALWAYS" style="-fx-padding: 5 0 0 0">
<HBox>
<Button text="" onAction="#scanQrAddress" prefHeight="30">
<graphic>
@@ -44,7 +44,7 @@
<CopyableLabel fx:id="derivationPath" />
</Field>
<Field fx:id="lastUsedField" text="Last Used:">
<Label fx:id="lastUsed" contentDisplay="RIGHT" />
<Label fx:id="lastUsed" />
</Field>
</Fieldset>
</Form>
@@ -9,7 +9,6 @@
<?import tornadofx.control.Fieldset?>
<?import tornadofx.control.Form?>
<?import tornadofx.control.Field?>
<?import com.sparrowwallet.sparrow.control.CopyableTextField?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
@@ -91,6 +90,14 @@
</Field>
<Field fx:id="feeRateField" text="Rate:">
<CopyableLabel fx:id="feeRate" />
<Label fx:id="cpfpFeeRate" text="CPFP">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="SIGN_OUT_ALT" />
</graphic>
<padding>
<Insets left="10"/>
</padding>
</Label>
<Region HBox.hgrow="ALWAYS" />
<Label fx:id="feeRatePriority" graphicTextGap="5" contentDisplay="RIGHT">
<graphic>
@@ -83,7 +83,7 @@
<DescriptorArea fx:id="descriptor" editable="false" styleClass="uneditable-codearea" prefHeight="27" maxHeight="27" />
</content>
</VirtualizedScrollPane>
<Button onAction="#scanDescriptorQR">
<Button fx:id="scanDescriptorQR" onAction="#scanDescriptorQR">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>
@@ -91,6 +91,14 @@
<Tooltip text="Scan a QR code" />
</tooltip>
</Button>
<Button fx:id="showDescriptorQR" onAction="#showDescriptorQR">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="QRCODE" />
</graphic>
<tooltip>
<Tooltip text="Show as QR code" />
</tooltip>
</Button>
<Button text="Edit..." onAction="#editDescriptor" />
</Field>
</Fieldset>
@@ -43,7 +43,7 @@
}
.transaction-row.confirming {
-fx-opacity: 0.8;
-fx-opacity: 0.7;
}
.utxo-row.unspendable {
@@ -75,6 +75,14 @@
-fx-padding: 0 8 0 0;
}
.coin-cell.negative-amount {
-fx-text-fill: rgb(202, 18, 67);
}
.tree-table-row-cell:selected .coin-cell.negative-amount {
-fx-text-fill: white;
}
.confirmation-progress-circle, .confirmation-progress-tick {
-fx-stroke: -fx-text-base-color;
}
@@ -64,7 +64,7 @@ public class ColdcardMultisigTest extends IoTest {
Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2WSH, wallet.getScriptType());
Assert.assertEquals(2, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("wsh(sortedmulti(2,coldcard1,coldcard2,coldcard3,coldcard4))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("wsh(sortedmulti(2,coldcard1,coldcard2,coldcard3,coldcard4))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertTrue(wallet.isValid());
}
@@ -77,7 +77,7 @@ public class ColdcardMultisigTest extends IoTest {
Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2SH_P2WSH, wallet.getScriptType());
Assert.assertEquals(2, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("sh(wsh(sortedmulti(2,coldcard1,coldcard2,coldcard3,coldcard4)))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("sh(wsh(sortedmulti(2,coldcard1,coldcard2,coldcard3,coldcard4)))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertTrue(wallet.isValid());
}
@@ -89,7 +89,7 @@ public class ColdcardMultisigTest extends IoTest {
Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2WSH, wallet.getScriptType());
Assert.assertEquals(3, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("wsh(sortedmulti(3,coldcard1,coldcard2,coldcard3))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("wsh(sortedmulti(3,coldcard1,coldcard2,coldcard3))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("06b57041", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/48'/0'/0'/2'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub6EfEGa5isJbQFSswM5Uptw5BSq2Td1ZDJr3QUNUcMySpC7itZ3ccypVHtLPnvMzKQ2qxrAgH49vhVxRcaQLFbixAVRR8RACrYTp88Uv9h8Z", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -24,7 +24,7 @@ public class ElectrumTest extends IoTest {
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2SH_P2WPKH, wallet.getScriptType());
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("sh(wpkh(trezortest))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("sh(wpkh(trezortest))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("ab543c67", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/49'/0'/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub6FFEQVG6QR28chQzgSJ7Gjx5j5BGLkCMgZ9bc41YJCXfwYiCKUQdcwm4Fe1stvzRjosz5udMedYZFRL56AeZXCsiVmnVUysio4jkAKTukmN", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -44,7 +44,7 @@ public class ElectrumTest extends IoTest {
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2SH_P2WPKH, wallet.getScriptType());
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("sh(wpkh(trezortest))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("sh(wpkh(trezortest))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("ab543c67", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/49'/0'/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub6FFEQVG6QR28chQzgSJ7Gjx5j5BGLkCMgZ9bc41YJCXfwYiCKUQdcwm4Fe1stvzRjosz5udMedYZFRL56AeZXCsiVmnVUysio4jkAKTukmN", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -59,7 +59,7 @@ public class ElectrumTest extends IoTest {
Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2SH_P2WSH, wallet.getScriptType());
Assert.assertEquals(2, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("sh(wsh(sortedmulti(2,coldcard6ba6cfd,coldcard747b698,coldcard7bb026b,coldcard0f05694)))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("sh(wsh(sortedmulti(2,coldcard6ba6cfd,coldcard747b698,coldcard7bb026b,coldcard0f05694)))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("6ba6cfd0", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/48'/1'/0'/1'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("tpubDFcrvj5n7gyatVbr8dHCUfHT4CGvL8hREBjtxc4ge7HZgqNuPhFimPRtVg6fRRwfXiQthV9EBjNbwbpgV2VoQeL1ZNXoAWXxP2L9vMtRjax", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -82,7 +82,7 @@ public class ElectrumTest extends IoTest {
Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2SH_P2WSH, wallet.getScriptType());
Assert.assertEquals(2, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("sh(wsh(sortedmulti(2,coldcard6ba6cfd,coldcard747b698,coldcard7bb026b,coldcard0f05694)))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("sh(wsh(sortedmulti(2,coldcard6ba6cfd,coldcard747b698,coldcard7bb026b,coldcard0f05694)))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("6ba6cfd0", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/48'/1'/0'/1'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("tpubDFcrvj5n7gyatVbr8dHCUfHT4CGvL8hREBjtxc4ge7HZgqNuPhFimPRtVg6fRRwfXiQthV9EBjNbwbpgV2VoQeL1ZNXoAWXxP2L9vMtRjax", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -100,7 +100,7 @@ public class ElectrumTest extends IoTest {
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("wpkh(electrum)", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("wpkh(electrum)", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("f881eac5", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub69iSRreMB6fu24sU8Tdxv7yYGqzPkDwPkwqUfKJTxW3p8afW7XvTewVCapuX3dQjdD197iF65WcjYaNpFbwWT3RyuZ1KJ3ToJNVWKWyAJ6f", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -120,7 +120,7 @@ public class ElectrumTest extends IoTest {
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("wpkh(electrum)", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("wpkh(electrum)", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("59c5474f", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub68YmVxWbxqjpxbUqqaPrgkBQPBSJuq6gEaL22uuytSEojtS2x5eLPN2uspUuyigtnMkoHrFSF1KwoXPwjzuaUjErUwztxfHquAwuaQhSd9J", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -15,7 +15,7 @@ public class SpecterDesktopTest extends IoTest {
Assert.assertEquals(PolicyType.SINGLE, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2SH_P2WPKH, wallet.getScriptType());
Assert.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("sh(wpkh(keystore1))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("sh(wpkh(keystore1))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("4df18faa", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/49'/0'/0'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub6BgwyseZdeGJj2vB3FPHSGPxR1LLkr8AsAJqedrgjwBXKXXVWkH31fhwtQXgrM7uMrWjLwXhuDhhenNAh5eBdUSjrHkrKfaXutcJdAfgQ8D", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
@@ -30,7 +30,7 @@ public class SpecterDesktopTest extends IoTest {
Assert.assertEquals(PolicyType.MULTI, wallet.getPolicyType());
Assert.assertEquals(ScriptType.P2WSH, wallet.getScriptType());
Assert.assertEquals(3, wallet.getDefaultPolicy().getNumSignaturesRequired());
Assert.assertEquals("wsh(sortedmulti(3,keystore1,keystore2,keystore3,keystore4))", wallet.getDefaultPolicy().getMiniscript().getScript());
Assert.assertEquals("wsh(sortedmulti(3,keystore1,keystore2,keystore3,keystore4))", wallet.getDefaultPolicy().getMiniscript().getScript().toLowerCase());
Assert.assertEquals("ca9a2b19", wallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
Assert.assertEquals("m/48'/0'/0'/2'", wallet.getKeystores().get(0).getKeyDerivation().getDerivationPath());
Assert.assertEquals("xpub6EhbRDNhmMX863W8RujJyAMw1vtM4MHXnsk14paK1ZBEH75k44gWqfaraXCrzg6w9pzC2yLc28vAdUfpB9ShuEB1HA9xMs6BjmRi4PKbt1K", wallet.getKeystores().get(0).getExtendedPublicKey().toString());