diff --git a/build.gradle b/build.gradle index e22b91e0..e80b11e3 100644 --- a/build.gradle +++ b/build.gradle @@ -91,7 +91,7 @@ dependencies { implementation('org.slf4j:jul-to-slf4j:1.7.30') { exclude group: 'org.slf4j' } - implementation('com.sparrowwallet.nightjar:nightjar:0.2.6') + implementation('com.sparrowwallet.nightjar:nightjar:0.2.10') testImplementation('junit:junit:4.12') } @@ -387,7 +387,7 @@ extraJavaModuleInfo { module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') { exports('co.nstant.in.cbor') } - module('nightjar-0.2.6.jar', 'com.sparrowwallet.nightjar', '0.2.6') { + module('nightjar-0.2.10.jar', 'com.sparrowwallet.nightjar', '0.2.10') { requires('com.google.common') requires('net.sourceforge.streamsupport') requires('org.slf4j') diff --git a/drongo b/drongo index 2eedd229..81c20219 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 2eedd2290cbe1dd559247f1ee934cece81fa7419 +Subproject commit 81c202198e8b057271414d15259df556a90bc6f1 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index a7df3be4..52a11b9e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -35,6 +35,8 @@ import de.codecentric.centerdevice.MenuToolkit; import javafx.animation.*; import javafx.application.Platform; import javafx.beans.binding.Bindings; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.WeakChangeListener; import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; @@ -161,6 +163,10 @@ public class AppController implements Initializable { private final Set emptyLoadingWallets = new LinkedHashSet<>(); + private final ChangeListener serverToggleOnlineListener = (observable, oldValue, newValue) -> { + Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight())); + }; + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -284,9 +290,7 @@ public class AppController implements Initializable { serverToggle.setSelected(isConnected()); serverToggle.setDisable(Config.get().getServerType() == null); onlineProperty().bindBidirectional(serverToggle.selectedProperty()); - onlineProperty().addListener((observable, oldValue, newValue) -> { - Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight())); - }); + onlineProperty().addListener(new WeakChangeListener<>(serverToggleOnlineListener)); serverToggle.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { Config.get().setMode(serverToggle.isSelected() ? Mode.ONLINE : Mode.OFFLINE); }); @@ -895,7 +899,7 @@ public class AppController implements Initializable { if(wallet.isWhirlpoolMasterWallet()) { String walletId = storage.getWalletId(wallet); Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId); - whirlpool.setHDWallet(copy); + whirlpool.setHDWallet(storage.getWalletId(wallet), copy); } for(int i = 0; i < wallet.getKeystores().size(); i++) { @@ -1185,6 +1189,7 @@ public class AppController implements Initializable { TabPane subTabs = new TabPane(); subTabs.setSide(Side.RIGHT); subTabs.getStyleClass().add("master-only"); + subTabs.rotateGraphicProperty().set(true); tab.setContent(subTabs); WalletForm walletForm = addWalletSubTab(subTabs, storage, wallet, backupWallet); @@ -1213,7 +1218,13 @@ public class AppController implements Initializable { if(walletTabData.getWallet() == wallet.getMasterWallet()) { TabPane subTabs = (TabPane)walletTab.getContent(); addWalletSubTab(subTabs, storage, wallet, backupWallet); - Platform.runLater(() -> subTabs.getStyleClass().remove("master-only")); + Tab masterTab = subTabs.getTabs().get(0); + Label masterLabel = (Label)masterTab.getGraphic(); + masterLabel.setText(getAutomaticName(wallet.getMasterWallet())); + Platform.runLater(() -> { + subTabs.getStyleClass().remove("master-only"); + subTabs.getStyleClass().add("wallet-subtabs"); + }); } } } @@ -1222,8 +1233,13 @@ public class AppController implements Initializable { public WalletForm addWalletSubTab(TabPane subTabs, Storage storage, Wallet wallet, Wallet backupWallet) { try { - Tab subTab = new Tab(wallet.isMasterWallet() ? getAutomaticName(wallet) : wallet.getName()); + Tab subTab = new Tab(); subTab.setClosable(false); + Label subTabLabel = new Label(wallet.isMasterWallet() ? getAutomaticName(wallet) : wallet.getName()); + subTabLabel.setGraphic(getSubTabGlyph(wallet)); + subTabLabel.setContentDisplay(ContentDisplay.TOP); + subTabLabel.setAlignment(Pos.TOP_CENTER); + subTab.setGraphic(subTabLabel); FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml")); subTab.setContent(walletLoader.load()); WalletController controller = walletLoader.getController(); @@ -1247,9 +1263,26 @@ public class AppController implements Initializable { } } + private Glyph getSubTabGlyph(Wallet wallet) { + Glyph tabGlyph; + StandardAccount standardAccount = wallet.getStandardAccountType(); + if(standardAccount == StandardAccount.WHIRLPOOL_PREMIX) { + tabGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM); + } else if(standardAccount == StandardAccount.WHIRLPOOL_POSTMIX) { + tabGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND); + } else if(standardAccount == StandardAccount.WHIRLPOOL_BADBANK) { + tabGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BIOHAZARD); + } else { + tabGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN); + } + + tabGlyph.setFontSize(12); + return tabGlyph; + } + private String getAutomaticName(Wallet wallet) { int account = wallet.getAccountIndex(); - return account < 0 ? wallet.getName() : "Account #" + account; + return account < 0 ? wallet.getName() : (!wallet.isWhirlpoolMasterWallet() || account > 1 ? "Account #" + account : "Deposit"); } public WalletForm getSelectedWalletForm() { @@ -1637,9 +1670,30 @@ public class AppController implements Initializable { @Subscribe public void newWalletTransactions(NewWalletTransactionsEvent event) { if(Config.get().isNotifyNewTransactions() && getOpenWallets().containsKey(event.getWallet())) { - String text; - if(event.getBlockTransactions().size() == 1) { - BlockTransaction blockTransaction = event.getBlockTransactions().get(0); + List blockTransactions = new ArrayList<>(event.getBlockTransactions()); + List whirlpoolTransactions = event.getWhirlpoolMixTransactions(); + blockTransactions.removeAll(whirlpoolTransactions); + + if(!whirlpoolTransactions.isEmpty()) { + BlockTransaction blockTransaction = whirlpoolTransactions.get(0); + String status; + String walletName = event.getWallet().getMasterName() + " " + event.getWallet().getName().toLowerCase(); + long value = blockTransaction.getTransaction().getOutputs().iterator().next().getValue(); + long mempoolValue = whirlpoolTransactions.stream().filter(tx -> tx.getHeight() <= 0).mapToLong(tx -> value).sum(); + long blockchainValue = whirlpoolTransactions.stream().filter(tx -> tx.getHeight() > 0).mapToLong(tx -> value).sum(); + + if(mempoolValue > 0) { + status = "New " + walletName + " mempool transaction" + (mempoolValue > value ? "s: " : ": ") + event.getValueAsText(mempoolValue); + } else { + status = "Confirming " + walletName + " transaction" + (blockchainValue > value ? "s: " : ": ") + event.getValueAsText(blockchainValue); + } + + statusUpdated(new StatusEvent(status)); + } + + String text = null; + if(blockTransactions.size() == 1) { + BlockTransaction blockTransaction = blockTransactions.get(0); if(blockTransaction.getHeight() <= 0) { text = "New mempool transaction: "; } else { @@ -1654,7 +1708,7 @@ public class AppController implements Initializable { } text += event.getValueAsText(event.getTotalValue()); - } else { + } else if(blockTransactions.size() > 1) { if(event.getTotalBlockchainValue() > 0 && event.getTotalMempoolValue() > 0) { text = "New transactions: " + event.getValueAsText(event.getTotalValue()) + " total (" + event.getValueAsText(event.getTotalMempoolValue()) + " in mempool)"; } else if(event.getTotalMempoolValue() > 0) { @@ -1664,29 +1718,31 @@ public class AppController implements Initializable { } } - Window.getWindows().forEach(window -> { - String notificationStyles = AppController.class.getResource("notificationpopup.css").toExternalForm(); - if(!window.getScene().getStylesheets().contains(notificationStyles)) { - window.getScene().getStylesheets().add(notificationStyles); + if(text != null) { + Window.getWindows().forEach(window -> { + String notificationStyles = AppController.class.getResource("notificationpopup.css").toExternalForm(); + if(!window.getScene().getStylesheets().contains(notificationStyles)) { + window.getScene().getStylesheets().add(notificationStyles); + } + }); + + Image image = new Image("image/sparrow-small.png", 50, 50, false, false); + Notifications notificationBuilder = Notifications.create() + .title("Sparrow - " + event.getWallet().getFullName()) + .text(text) + .graphic(new ImageView(image)) + .hideAfter(Duration.seconds(15)) + .position(Pos.TOP_RIGHT) + .threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new ImageView(image))) + .onAction(e -> selectTab(event.getWallet())); + + //If controlsfx can't find our window, we must set the window ourselves (unfortunately notification is then shown within this window) + if(org.controlsfx.tools.Utils.getWindow(null) == null) { + notificationBuilder.owner(tabs.getScene().getWindow()); } - }); - Image image = new Image("image/sparrow-small.png", 50, 50, false, false); - Notifications notificationBuilder = Notifications.create() - .title("Sparrow - " + event.getWallet().getFullName()) - .text(text) - .graphic(new ImageView(image)) - .hideAfter(Duration.seconds(15)) - .position(Pos.TOP_RIGHT) - .threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new ImageView(image))) - .onAction(e -> selectTab(event.getWallet())); - - //If controlsfx can't find our window, we must set the window ourselves (unfortunately notification is then shown within this window) - if(org.controlsfx.tools.Utils.getWindow(null) == null) { - notificationBuilder.owner(tabs.getScene().getWindow()); + notificationBuilder.show(); } - - notificationBuilder.show(); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 8a6f67ff..0310ec83 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -452,6 +452,19 @@ public class AppServices { return application; } + public Whirlpool getWhirlpool(Wallet wallet) { + Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + for(List walletTabDataList : walletWindows.values()) { + for(WalletTabData walletTabData : walletTabDataList) { + if(walletTabData.getWallet() == masterWallet) { + return whirlpoolMap.get(walletTabData.getWalletForm().getWalletId()); + } + } + } + + return null; + } + public Whirlpool getWhirlpool(String walletId) { Whirlpool whirlpool = whirlpoolMap.get(walletId); if(whirlpool == null) { @@ -473,11 +486,11 @@ public class AppServices { } } - private void stopAllWhirlpool() { + private void shutdownAllWhirlpool() { for(Whirlpool whirlpool : whirlpoolMap.values().stream().filter(Whirlpool::isStarted).collect(Collectors.toList())) { Whirlpool.ShutdownService shutdownService = new Whirlpool.ShutdownService(whirlpool); shutdownService.setOnFailed(workerStateEvent -> { - log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException()); + log.error("Failed to shutdown whirlpool", workerStateEvent.getSource().getException()); }); shutdownService.start(); } @@ -511,6 +524,10 @@ public class AppServices { return openWallets; } + public Wallet getWallet(String walletId) { + return getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElse(null); + } + public Window getWindowForWallet(String walletId) { Optional optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWalletForm().getWalletId().equals(walletId))).map(Map.Entry::getKey).findFirst(); return optWindow.orElse(null); @@ -824,7 +841,7 @@ public class AppServices { @Subscribe public void disconnection(DisconnectionEvent event) { - stopAllWhirlpool(); + shutdownAllWhirlpool(); } @Subscribe @@ -975,7 +992,7 @@ public class AppServices { WhirlpoolEventService.getInstance().unregister(whirlpool); }); shutdownService.setOnFailed(workerStateEvent -> { - log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException()); + log.error("Failed to shutdown whirlpool", workerStateEvent.getSource().getException()); }); shutdownService.start(); } else { @@ -987,6 +1004,14 @@ public class AppServices { } } + @Subscribe + public void walletHistoryChanged(WalletHistoryChangedEvent event) { + Whirlpool whirlpool = getWhirlpool(event.getWallet()); + if(whirlpool != null) { + whirlpool.refreshUtxos(); + } + } + private void restartBwt(Wallet wallet) { if(Config.get().getServerType() == ServerType.BITCOIN_CORE && isConnected() && wallet.isValid()) { connectionService.cancel(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java b/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java index 00c056a4..d1112e38 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java @@ -11,55 +11,44 @@ import javafx.scene.control.Tooltip; import javafx.scene.control.TreeTableCell; import org.controlsfx.glyphfont.Glyph; -public class AddressCell extends TreeTableCell { +public class AddressCell extends TreeTableCell { public AddressCell() { super(); setAlignment(Pos.CENTER_LEFT); setContentDisplay(ContentDisplay.RIGHT); + getStyleClass().add("address-cell"); } @Override - protected void updateItem(Entry entry, boolean empty) { - super.updateItem(entry, empty); + protected void updateItem(UtxoEntry.AddressStatus addressStatus, boolean empty) { + super.updateItem(addressStatus, empty); - EntryCell.applyRowStyles(this, entry); - getStyleClass().add("address-cell"); + UtxoEntry utxoEntry = addressStatus == null ? null : addressStatus.getUtxoEntry(); + EntryCell.applyRowStyles(this, utxoEntry); if (empty) { setText(null); setGraphic(null); } else { - if(entry instanceof UtxoEntry) { - UtxoEntry utxoEntry = (UtxoEntry)entry; - Address address = utxoEntry.getAddress(); + if(utxoEntry != null) { + Address address = addressStatus.getAddress(); setText(address.toString()); setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()))); Tooltip tooltip = new Tooltip(); - tooltip.setText(getTooltipText(utxoEntry)); + tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate())); setTooltip(tooltip); - if(utxoEntry.isDuplicateAddress()) { + if(addressStatus.isDuplicate()) { setGraphic(getDuplicateGlyph()); } else { setGraphic(null); } - - utxoEntry.duplicateAddressProperty().addListener((observable, oldValue, newValue) -> { - if(newValue) { - setGraphic(getDuplicateGlyph()); - Tooltip tt = new Tooltip(); - tt.setText(getTooltipText(utxoEntry)); - setTooltip(tt); - } else { - setGraphic(null); - } - }); } } } - private String getTooltipText(UtxoEntry utxoEntry) { - return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (utxoEntry.isDuplicateAddress() ? " (Duplicate address)" : ""); + private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate) { + return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (duplicate ? " (Duplicate address)" : ""); } public static Glyph getDuplicateGlyph() { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java index 03c0f213..ec72a686 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java @@ -34,7 +34,7 @@ public class AddressTreeTable extends CoinTreeTable { addressCol.setSortable(false); getColumns().add(addressCol); - if(address != null) { + if(address != null && !rootEntry.getWallet().isWhirlpoolMixWallet()) { addressCol.setMinWidth(TextUtils.computeTextWidth(AppServices.getMonospaceFont(), address, 0.0)); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DateCell.java b/src/main/java/com/sparrowwallet/sparrow/control/DateCell.java index c9223ef6..348a0f00 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DateCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DateCell.java @@ -34,7 +34,7 @@ public class DateCell extends TreeTableCell { if(entry instanceof UtxoEntry) { UtxoEntry utxoEntry = (UtxoEntry)entry; if(utxoEntry.getHashIndex().getHeight() <= 0) { - setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")); + setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"))); } else { String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate()); setText(date); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 30745f89..3cb0724a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -130,6 +130,12 @@ public class EntryCell extends TreeTableCell { } setGraphic(actionBox); + + if(nodeEntry.getWallet().isWhirlpoolMixWallet()) { + setText(address.toString().substring(0, 20) + "..."); + setContextMenu(null); + setGraphic(new HBox()); + } } else if(entry instanceof HashIndexEntry) { HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; setText(hashIndexEntry.getDescription()); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MixStatusCell.java b/src/main/java/com/sparrowwallet/sparrow/control/MixStatusCell.java new file mode 100644 index 00000000..ce849cab --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/MixStatusCell.java @@ -0,0 +1,167 @@ +package com.sparrowwallet.sparrow.control; + +import com.samourai.whirlpool.client.mix.listener.MixFailReason; +import com.samourai.whirlpool.client.mix.listener.MixStep; +import com.samourai.whirlpool.client.wallet.beans.MixProgress; +import com.samourai.whirlpool.protocol.beans.Utxo; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import com.sparrowwallet.sparrow.whirlpool.WhirlpoolException; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import org.controlsfx.glyphfont.Glyph; + +public class MixStatusCell extends TreeTableCell { + public MixStatusCell() { + super(); + setAlignment(Pos.CENTER_RIGHT); + setContentDisplay(ContentDisplay.LEFT); + setGraphicTextGap(8); + getStyleClass().add("mixstatus-cell"); + } + + @Override + protected void updateItem(UtxoEntry.MixStatus mixStatus, boolean empty) { + super.updateItem(mixStatus, empty); + + EntryCell.applyRowStyles(this, mixStatus == null ? null : mixStatus.getUtxoEntry()); + + if(empty || mixStatus == null) { + setText(null); + setGraphic(null); + } else { + setText(Integer.toString(mixStatus.getMixesDone())); + if(mixStatus.getNextMixUtxo() == null) { + setContextMenu(new MixStatusContextMenu(mixStatus.getUtxoEntry(), mixStatus.getMixProgress() != null && mixStatus.getMixProgress().getMixStep() != MixStep.FAIL)); + } else { + setContextMenu(null); + } + + if(mixStatus.getPoolId() != null) { + Tooltip tooltip = new Tooltip(); + tooltip.setText("Pool: " + mixStatus.getPoolId().replace("btc", " BTC")); + setTooltip(tooltip); + } + + if(mixStatus.getNextMixUtxo() != null) { + setMixSuccess(mixStatus.getNextMixUtxo()); + } else if(mixStatus.getMixFailReason() != null) { + setMixFail(mixStatus.getMixFailReason()); + } else if(mixStatus.getMixProgress() != null) { + setMixProgress(mixStatus.getMixProgress()); + } else { + setGraphic(null); + } + } + } + + private void setMixSuccess(Utxo nextMixUtxo) { + ProgressIndicator progressIndicator = getProgressIndicator(); + progressIndicator.setProgress(-1); + setGraphic(progressIndicator); + Tooltip tt = new Tooltip(); + tt.setText("Waiting for broadcast of " + nextMixUtxo.getHash().substring(0, 8) + "..." + ":" + nextMixUtxo.getIndex() ); + setTooltip(tt); + } + + private void setMixFail(MixFailReason mixFailReason) { + if(mixFailReason != MixFailReason.CANCEL) { + setGraphic(getFailGlyph()); + Tooltip tt = new Tooltip(); + tt.setText(mixFailReason.getMessage()); + setTooltip(tt); + } else { + setGraphic(null); + } + } + + private void setMixProgress(MixProgress mixProgress) { + if(mixProgress.getMixStep() != MixStep.FAIL) { + ProgressIndicator progressIndicator = getProgressIndicator(); + progressIndicator.setProgress(mixProgress.getProgressPercent() == 100 ? -1 : mixProgress.getProgressPercent() / 100.0); + setGraphic(progressIndicator); + Tooltip tt = new Tooltip(); + tt.setText(mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase() + mixProgress.getMixStep().getMessage().substring(1)); + setTooltip(tt); + } else { + setGraphic(null); + } + } + + private ProgressIndicator getProgressIndicator() { + ProgressIndicator progressIndicator; + if(getGraphic() instanceof ProgressIndicator) { + progressIndicator = (ProgressIndicator)getGraphic(); + } else { + progressIndicator = new ProgressBar(); + } + + return progressIndicator; + } + + private static Glyph getMixGlyph() { + Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM); + copyGlyph.setFontSize(12); + return copyGlyph; + } + + private static Glyph getStopGlyph() { + Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.STOP_CIRCLE); + copyGlyph.setFontSize(12); + return copyGlyph; + } + + public static Glyph getFailGlyph() { + Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE); + failGlyph.getStyleClass().add("fail-warning"); + failGlyph.setFontSize(12); + return failGlyph; + } + + private static class MixStatusContextMenu extends ContextMenu { + public MixStatusContextMenu(UtxoEntry utxoEntry, boolean isMixing) { + Whirlpool pool = AppServices.get().getWhirlpool(utxoEntry.getWallet()); + if(isMixing) { + MenuItem mixStop = new MenuItem("Stop Mixing"); + if(pool != null) { + mixStop.disableProperty().bind(pool.mixingProperty().not()); + } + mixStop.setGraphic(getStopGlyph()); + mixStop.setOnAction(event -> { + hide(); + Whirlpool whirlpool = AppServices.get().getWhirlpool(utxoEntry.getWallet()); + if(whirlpool != null) { + try { + whirlpool.mixStop(utxoEntry.getHashIndex()); + } catch(WhirlpoolException e) { + AppServices.showErrorDialog("Error stopping mixing UTXO", e.getMessage()); + } + } + }); + getItems().add(mixStop); + } else { + MenuItem mixNow = new MenuItem("Mix Now"); + if(pool != null) { + mixNow.disableProperty().bind(pool.mixingProperty().not()); + } + + mixNow.setGraphic(getMixGlyph()); + mixNow.setOnAction(event -> { + hide(); + Whirlpool whirlpool = AppServices.get().getWhirlpool(utxoEntry.getWallet()); + if(whirlpool != null) { + try { + whirlpool.mix(utxoEntry.getHashIndex()); + } catch(WhirlpoolException e) { + AppServices.showErrorDialog("Error mixing UTXO", e.getMessage()); + } + } + }); + getItems().add(mixNow); + } + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index 55a4104a..0c190b16 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -247,7 +247,7 @@ public class QRScanDialog extends Dialog { //ignore, bytes not parsable as tx } - result = new Result(new ScanException("Parsed QR parts were not a PSBT or transaction")); + result = new Result(complete); } } else { PSBT psbt; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionHexArea.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionHexArea.java index 45ce3885..b0d1aae0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionHexArea.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionHexArea.java @@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.protocol.*; import javafx.application.Platform; import javafx.geometry.Point2D; import javafx.scene.control.ContextMenu; +import javafx.scene.control.IndexRange; import javafx.scene.control.Label; import javafx.scene.control.MenuItem; import javafx.scene.input.Clipboard; @@ -25,6 +26,7 @@ public class TransactionHexArea extends CodeArea { private static final int TRUNCATE_AT = 30000; private static final int SEGMENTS_INTERVAL = 250; + private String fullHex; private List previousSegmentList = new ArrayList<>(); public TransactionHexArea() { @@ -37,7 +39,7 @@ public class TransactionHexArea extends CodeArea { ByteArrayOutputStream baos = new ByteArrayOutputStream(); transaction.bitcoinSerializeToStream(baos); - String fullHex = Utils.bytesToHex(baos.toByteArray()); + fullHex = Utils.bytesToHex(baos.toByteArray()); String hex = fullHex; if(hex.length() > TRUNCATE_AT) { hex = hex.substring(0, TRUNCATE_AT); @@ -242,6 +244,18 @@ public class TransactionHexArea extends CodeArea { }; } + @Override + public void copy() { + IndexRange selection = getSelection(); + if(fullHex != null && selection.getLength() == getLength()) { + ClipboardContent content = new ClipboardContent(); + content.putString(fullHex); + Clipboard.getSystemClipboard().setContent(content); + } else { + super.copy(); + } + } + private static class TransactionSegment { public TransactionSegment(int start, int length, Integer index, Integer witnessIndex, String style) { this.start = start; diff --git a/src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java index 72ff859d..611ca75a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java @@ -7,6 +7,7 @@ import javafx.scene.control.SelectionMode; import javafx.scene.control.TreeTableColumn; import javafx.scene.control.TreeTableView; +import java.util.Comparator; import java.util.List; public class UtxosTreeTable extends CoinTreeTable { @@ -38,18 +39,25 @@ public class UtxosTreeTable extends CoinTreeTable { }); getColumns().add(outputCol); - TreeTableColumn addressCol = new TreeTableColumn<>("Address"); - addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { - return new ReadOnlyObjectWrapper<>(param.getValue().getValue()); - }); - addressCol.setCellFactory(p -> new AddressCell()); - addressCol.setSortable(true); - addressCol.setComparator((o1, o2) -> { - UtxoEntry entry1 = (UtxoEntry)o1; - UtxoEntry entry2 = (UtxoEntry)o2; - return entry1.getAddress().toString().compareTo(entry2.getAddress().toString()); - }); - getColumns().add(addressCol); + if(rootEntry.getWallet().isWhirlpoolMixWallet()) { + TreeTableColumn mixStatusCol = new TreeTableColumn<>("Mixes"); + mixStatusCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { + return ((UtxoEntry)param.getValue().getValue()).mixStatusProperty(); + }); + mixStatusCol.setCellFactory(p -> new MixStatusCell()); + mixStatusCol.setSortable(true); + mixStatusCol.setComparator(Comparator.comparingInt(UtxoEntry.MixStatus::getMixesDone)); + getColumns().add(mixStatusCol); + } else { + TreeTableColumn addressCol = new TreeTableColumn<>("Address"); + addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { + return ((UtxoEntry)param.getValue().getValue()).addressStatusProperty(); + }); + addressCol.setCellFactory(p -> new AddressCell()); + addressCol.setSortable(true); + addressCol.setComparator(Comparator.comparing(o -> o.getAddress().toString())); + getColumns().add(addressCol); + } TreeTableColumn labelCol = new TreeTableColumn<>("Label"); labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures param) -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java index c7bb6570..4eb87fe9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java @@ -48,7 +48,7 @@ public class WalletImportDialog extends Dialog { AnchorPane.setRightAnchor(scrollPane, 0.0); importAccordion = new Accordion(); - List keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new SpecterDIY()); + List keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SpecterDIY()); for(KeystoreFileImport importer : keystoreImporters) { FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer); importAccordion.getPanes().add(importPane); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/NewWalletTransactionsEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/NewWalletTransactionsEvent.java index 3576e2c5..5ca23e73 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/NewWalletTransactionsEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/NewWalletTransactionsEvent.java @@ -6,21 +6,25 @@ import com.sparrowwallet.drongo.wallet.BlockTransaction; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.control.CoinLabel; import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.TransactionEntry; +import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; public class NewWalletTransactionsEvent { private final Wallet wallet; - private final List blockTransactions; + private final List transactionEntries; private final long totalBlockchainValue; private final long totalMempoolValue; - public NewWalletTransactionsEvent(Wallet wallet, List blockTransactions, long totalBlockchainValue, long totalMempoolValue) { + public NewWalletTransactionsEvent(Wallet wallet, List transactionEntries) { this.wallet = wallet; - this.blockTransactions = blockTransactions; - this.totalBlockchainValue = totalBlockchainValue; - this.totalMempoolValue = totalMempoolValue; + this.transactionEntries = transactionEntries; + this.totalBlockchainValue = transactionEntries.stream().filter(txEntry -> txEntry.getConfirmations() > 0).mapToLong(Entry::getValue).sum(); + this.totalMempoolValue = transactionEntries.stream().filter(txEntry ->txEntry.getConfirmations() == 0).mapToLong(Entry::getValue).sum(); } public Wallet getWallet() { @@ -28,7 +32,7 @@ public class NewWalletTransactionsEvent { } public List getBlockTransactions() { - return blockTransactions; + return transactionEntries.stream().map(TransactionEntry::getBlockTransaction).collect(Collectors.toList()); } public long getTotalValue() { @@ -55,4 +59,13 @@ public class NewWalletTransactionsEvent { return String.format(Locale.ENGLISH, "%,d", value) + " sats"; } + + public List getWhirlpoolMixTransactions() { + List mixTransactions = new ArrayList<>(); + if(wallet.isWhirlpoolMixWallet()) { + return transactionEntries.stream().filter(txEntry -> txEntry.getValue() == 0).map(TransactionEntry::getBlockTransaction).collect(Collectors.toList()); + } + + return mixTransactions; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java index 152fb912..40badf36 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java @@ -19,8 +19,7 @@ public class WalletNodeHistoryChangedEvent { } public WalletNode getWalletNode(Wallet wallet) { - List keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE); - for(KeyPurpose keyPurpose : keyPurposes) { + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { WalletNode changedNode = getWalletNode(wallet, keyPurpose); if(changedNode != null) { return changedNode; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletUtxoMixesChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletUtxoMixesChangedEvent.java new file mode 100644 index 00000000..430cf2d8 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletUtxoMixesChangedEvent.java @@ -0,0 +1,26 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.wallet.UtxoMixData; +import com.sparrowwallet.drongo.wallet.Wallet; + +import java.util.Map; + +public class WalletUtxoMixesChangedEvent extends WalletChangedEvent { + private final Map changedUtxoMixes; + private final Map removedUtxoMixes; + + public WalletUtxoMixesChangedEvent(Wallet wallet, Map changedUtxoMixes, Map removedUtxoMixes) { + super(wallet); + this.changedUtxoMixes = changedUtxoMixes; + this.removedUtxoMixes = removedUtxoMixes; + } + + public Map getChangedUtxoMixes() { + return changedUtxoMixes; + } + + public Map getRemovedUtxoMixes() { + return removedUtxoMixes; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixEvent.java new file mode 100644 index 00000000..4cf6df58 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixEvent.java @@ -0,0 +1,59 @@ +package com.sparrowwallet.sparrow.event; + +import com.samourai.whirlpool.client.mix.listener.MixFailReason; +import com.samourai.whirlpool.client.wallet.beans.MixProgress; +import com.samourai.whirlpool.protocol.beans.Utxo; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; + +public class WhirlpoolMixEvent { + private final Wallet wallet; + private final BlockTransactionHashIndex utxo; + private final MixProgress mixProgress; + private final Utxo nextUtxo; + private final MixFailReason mixFailReason; + + public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, MixProgress mixProgress) { + this.wallet = wallet; + this.utxo = utxo; + this.mixProgress = mixProgress; + this.nextUtxo = null; + this.mixFailReason = null; + } + + public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, Utxo nextUtxo) { + this.wallet = wallet; + this.utxo = utxo; + this.mixProgress = null; + this.nextUtxo = nextUtxo; + this.mixFailReason = null; + } + + public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, MixFailReason mixFailReason) { + this.wallet = wallet; + this.utxo = utxo; + this.mixProgress = null; + this.nextUtxo = null; + this.mixFailReason = mixFailReason; + } + + public Wallet getWallet() { + return wallet; + } + + public BlockTransactionHashIndex getUtxo() { + return utxo; + } + + public MixProgress getMixProgress() { + return mixProgress; + } + + public Utxo getNextUtxo() { + return nextUtxo; + } + + public MixFailReason getMixFailReason() { + return mixFailReason; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixSuccessEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixSuccessEvent.java new file mode 100644 index 00000000..0eb1170e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixSuccessEvent.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.sparrow.event; + +import com.samourai.whirlpool.protocol.beans.Utxo; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; + +public class WhirlpoolMixSuccessEvent extends WhirlpoolMixEvent { + private final WalletNode walletNode; + + public WhirlpoolMixSuccessEvent(Wallet wallet, BlockTransactionHashIndex utxo, Utxo nextUtxo, WalletNode walletNode) { + super(wallet, utxo, nextUtxo); + this.walletNode = walletNode; + } + + public WalletNode getWalletNode() { + return walletNode; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index 86184e52..ef824231 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -44,7 +44,9 @@ public class FontAwesome5 extends GlyphFont { LOCK_OPEN('\uf3c1'), PEN_FANCY('\uf5ac'), PLUS('\uf067'), + PLAY_CIRCLE('\uf144'), PLUS_CIRCLE('\uf055'), + STOP_CIRCLE('\uf28d'), QRCODE('\uf029'), QUESTION_CIRCLE('\uf059'), RANDOM('\uf074'), diff --git a/src/main/java/com/sparrowwallet/sparrow/io/SeedSigner.java b/src/main/java/com/sparrowwallet/sparrow/io/SeedSigner.java index b5a11250..49e3bc26 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/SeedSigner.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/SeedSigner.java @@ -10,7 +10,7 @@ public class SeedSigner extends SpecterDIY { @Override public String getKeystoreImportDescription() { - return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports P2WSH Multisig wallets."; + return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports multisig wallets with a P2WSH script type."; } @Override diff --git a/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java b/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java index 64846d44..ed41467a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java @@ -30,7 +30,7 @@ public class SpecterDIY implements KeystoreFileImport, WalletExport { Keystore keystore = wallet.getKeystores().get(0); keystore.setLabel(getName()); - keystore.setWalletModel(WalletModel.SPECTER_DIY); + keystore.setWalletModel(getWalletModel()); keystore.setSource(KeystoreSource.HW_AIRGAPPED); return keystore; diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java index af772930..68f08258 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java @@ -272,6 +272,19 @@ public class DbPersistence implements Persistence { } } + if(!dirtyPersistables.changedUtxoMixes.isEmpty()) { + UtxoMixDataDao utxoMixDataDao = handle.attach(UtxoMixDataDao.class); + for(Map.Entry utxoMixDataEntry : dirtyPersistables.changedUtxoMixes.entrySet()) { + utxoMixDataDao.addOrUpdate(wallet, utxoMixDataEntry.getKey(), utxoMixDataEntry.getValue()); + } + } + + if(!dirtyPersistables.removedUtxoMixes.isEmpty()) { + UtxoMixDataDao utxoMixDataDao = handle.attach(UtxoMixDataDao.class); + List ids = dirtyPersistables.removedUtxoMixes.values().stream().map(Persistable::getId).filter(Objects::nonNull).collect(Collectors.toList()); + utxoMixDataDao.deleteUtxoMixData(ids); + } + if(!dirtyPersistables.labelKeystores.isEmpty()) { KeystoreDao keystoreDao = handle.attach(KeystoreDao.class); for(Keystore keystore : dirtyPersistables.labelKeystores) { @@ -639,6 +652,14 @@ public class DbPersistence implements Persistence { } } + @Subscribe + public void walletUtxoMixesChanged(WalletUtxoMixesChangedEvent event) { + if(persistsFor(event.getWallet())) { + dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).changedUtxoMixes.putAll(event.getChangedUtxoMixes()); + dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).removedUtxoMixes.putAll(event.getRemovedUtxoMixes()); + } + } + @Subscribe public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) { if(persistsFor(event.getWallet())) { @@ -659,6 +680,8 @@ public class DbPersistence implements Persistence { public Integer blockHeight = null; public final List labelEntries = new ArrayList<>(); public final List utxoStatuses = new ArrayList<>(); + public final Map changedUtxoMixes = new HashMap<>(); + public final Map removedUtxoMixes = new HashMap<>(); public final List labelKeystores = new ArrayList<>(); public final List encryptionKeystores = new ArrayList<>(); @@ -671,6 +694,8 @@ public class DbPersistence implements Persistence { "\nAddress labels:" + labelEntries.stream().filter(entry -> entry instanceof NodeEntry).map(entry -> ((NodeEntry)entry).getNode().toString() + " " + entry.getLabel()).collect(Collectors.toList()) + "\nUTXO labels:" + labelEntries.stream().filter(entry -> entry instanceof HashIndexEntry).map(entry -> ((HashIndexEntry)entry).getHashIndex().toString()).collect(Collectors.toList()) + "\nUTXO statuses:" + utxoStatuses + + "\nUTXO mixes changed:" + changedUtxoMixes + + "\nUTXO mixes removed:" + removedUtxoMixes + "\nKeystore labels:" + labelKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) + "\nKeystore encryptions:" + encryptionKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataDao.java new file mode 100644 index 00000000..ed5e8696 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataDao.java @@ -0,0 +1,56 @@ +package com.sparrowwallet.sparrow.io.db; + +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.wallet.UtxoMixData; +import com.sparrowwallet.drongo.wallet.Wallet; +import org.jdbi.v3.sqlobject.config.RegisterRowMapper; +import org.jdbi.v3.sqlobject.customizer.BindList; +import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys; +import org.jdbi.v3.sqlobject.statement.SqlQuery; +import org.jdbi.v3.sqlobject.statement.SqlUpdate; + +import java.util.List; +import java.util.Map; + +public interface UtxoMixDataDao { + @SqlQuery("select id, hash, poolId, mixesDone, forwarding from utxoMixData where wallet = ? order by id") + @RegisterRowMapper(UtxoMixDataMapper.class) + Map getForWalletId(Long id); + + @SqlQuery("select id, hash, poolId, mixesDone, forwarding from utxoMixData where hash = ?") + @RegisterRowMapper(UtxoMixDataMapper.class) + Map getForHash(byte[] hash); + + @SqlUpdate("insert into utxoMixData (hash, poolId, mixesDone, forwarding, wallet) values (?, ?, ?, ?, ?)") + @GetGeneratedKeys("id") + long insertUtxoMixData(byte[] hash, String poolId, int mixesDone, Long forwarding, long wallet); + + @SqlUpdate("update utxoMixData set hash = ?, poolId = ?, mixesDone = ?, forwarding = ?, wallet = ? where id = ?") + void updateUtxoMixData(byte[] hash, String poolId, int mixesDone, Long forwarding, long wallet, long id); + + @SqlUpdate("delete from utxoMixData where id in ()") + void deleteUtxoMixData(@BindList("ids") List ids); + + @SqlUpdate("delete from utxoMixData where wallet = ?") + void clear(long wallet); + + default void addUtxoMixData(Wallet wallet) { + for(Map.Entry utxoMixDataEntry : wallet.getUtxoMixes().entrySet()) { + utxoMixDataEntry.getValue().setId(null); + addOrUpdate(wallet, utxoMixDataEntry.getKey(), utxoMixDataEntry.getValue()); + } + } + + default void addOrUpdate(Wallet wallet, Sha256Hash hash, UtxoMixData utxoMixData) { + Map existing = getForHash(hash.getBytes()); + + if(existing.isEmpty() && utxoMixData.getId() == null) { + long id = insertUtxoMixData(hash.getBytes(), utxoMixData.getPoolId(), utxoMixData.getMixesDone(), utxoMixData.getForwarding(), wallet.getId()); + utxoMixData.setId(id); + } else { + Long existingId = existing.get(hash) != null ? existing.get(hash).getId() : utxoMixData.getId(); + updateUtxoMixData(hash.getBytes(), utxoMixData.getPoolId(), utxoMixData.getMixesDone(), utxoMixData.getForwarding(), wallet.getId(), existingId); + utxoMixData.setId(existingId); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataMapper.java b/src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataMapper.java new file mode 100644 index 00000000..3c784753 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataMapper.java @@ -0,0 +1,42 @@ +package com.sparrowwallet.sparrow.io.db; + +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.wallet.UtxoMixData; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; + +public class UtxoMixDataMapper implements RowMapper> { + @Override + public Map.Entry map(ResultSet rs, StatementContext ctx) throws SQLException { + Sha256Hash hash = Sha256Hash.wrap(rs.getBytes("hash")); + + Long forwarding = rs.getLong("forwarding"); + if(rs.wasNull()) { + forwarding = null; + } + + UtxoMixData utxoMixData = new UtxoMixData(rs.getString("poolId"), rs.getInt("mixesDone"), forwarding); + utxoMixData.setId(rs.getLong("id")); + + return new Map.Entry<>() { + @Override + public Sha256Hash getKey() { + return hash; + } + + @Override + public UtxoMixData getValue() { + return utxoMixData; + } + + @Override + public UtxoMixData setValue(UtxoMixData value) { + return null; + } + }; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java index c2bcf6e4..fcbac482 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java @@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io.db; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.UtxoMixData; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletNode; import org.jdbi.v3.sqlobject.CreateSqlObject; @@ -29,6 +30,9 @@ public interface WalletDao { @CreateSqlObject BlockTransactionDao createBlockTransactionDao(); + @CreateSqlObject + UtxoMixDataDao createUtxoMixDataDao(); + @SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id") @RegisterRowMapper(WalletMapper.class) List loadAllWallets(); @@ -86,6 +90,9 @@ public interface WalletDao { Map blockTransactions = createBlockTransactionDao().getForWalletId(wallet.getId()); //.stream().collect(Collectors.toMap(BlockTransaction::getHash, Function.identity(), (existing, replacement) -> existing, LinkedHashMap::new)); wallet.updateTransactions(blockTransactions); + + Map utxoMixes = createUtxoMixDataDao().getForWalletId(wallet.getId()); + wallet.getUtxoMixes().putAll(utxoMixes); } default void addWallet(String schema, Wallet wallet) { @@ -99,6 +106,7 @@ public interface WalletDao { createKeystoreDao().addKeystores(wallet); createWalletNodeDao().addWalletNodes(wallet); createBlockTransactionDao().addBlockTransactions(wallet); + createUtxoMixDataDao().addUtxoMixData(wallet); } finally { setSchema(DbPersistence.DEFAULT_SCHEMA); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 500aa4b2..5e55ee0f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -700,6 +700,21 @@ public class ElectrumServer { if(!transactionOutputs.equals(node.getTransactionOutputs())) { node.updateTransactionOutputs(transactionOutputs); + copyPostmixLabels(wallet, transactionOutputs); + } + } + + public void copyPostmixLabels(Wallet wallet, Set newTransactionOutputs) { + if(wallet.getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX) { + for(BlockTransactionHashIndex newRef : newTransactionOutputs) { + BlockTransactionHashIndex prevRef = wallet.getWalletTxos().keySet().stream() + .filter(txo -> wallet.getMasterWallet().getUtxoMixData(txo) != null && txo.isSpent() && txo.getSpentBy().getHash().equals(newRef.getHash())).findFirst().orElse(null); + if(prevRef != null && wallet.getMasterWallet().getUtxoMixData(newRef) != null) { + if(newRef.getLabel() == null && prevRef.getLabel() != null) { + newRef.setLabel(prevRef.getLabel()); + } + } + } } } @@ -828,8 +843,7 @@ public class ElectrumServer { public static Map getAllScriptHashes(Wallet wallet) { Map scriptHashes = new HashMap<>(); - List purposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE); - for(KeyPurpose keyPurpose : purposes) { + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { for(WalletNode childNode : wallet.getNode(keyPurpose).getChildren()) { scriptHashes.put(getScriptHash(wallet, childNode), childNode); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java b/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java index bfe0f7b9..e3371ab0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java @@ -79,8 +79,6 @@ public final class IpAddressMatcher { int nMaskFullBytes = nMaskBits / 8; byte finalByte = (byte) (0xFF00 >> (nMaskBits & 0x07)); - // System.out.println("Mask is " + new sun.misc.HexDumpEncoder().encode(mask)); - for (int i = 0; i < nMaskFullBytes; i++) { if (remAddr[i] != reqAddr[i]) { return false; diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 0372adf4..4e03946e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -859,7 +859,7 @@ public class HeadersController extends TransactionFormController implements Init broadcastTransactionService.setOnFailed(workerStateEvent -> { broadcastProgressBar.setProgress(0); log.error("Error broadcasting transaction", workerStateEvent.getSource().getException()); - AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error when broadcasting the transaction. The server response is contained in sparrow.log"); + AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error when broadcasting the transaction. The server response is contained in the log (See Help > Show Log File)."); broadcastButton.setDisable(false); }); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java index a81a5f49..38452b28 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java @@ -115,6 +115,7 @@ public class AddressesController extends WalletFormController implements Initial fileChooser.setTitle("Export Addresses to CSV"); fileChooser.setInitialFileName(getWalletForm().getWallet().getFullName() + "-" + keyPurpose.name().toLowerCase() + "-addresses.csv"); + boolean whirlpoolMixWallet = getWalletForm().getWallet().isWhirlpoolMixWallet(); Wallet copy = getWalletForm().getWallet().copy(); WalletNode purposeNode = copy.getNode(keyPurpose); purposeNode.fillToIndex(Math.max(purposeNode.getChildren().size(), DEFAULT_EXPORT_ADDRESSES_LENGTH)); @@ -127,7 +128,7 @@ public class AddressesController extends WalletFormController implements Initial writer.writeRecord(new String[] {"Index", "Payment Address", "Derivation", "Label"}); for(WalletNode indexNode : purposeNode.getChildren()) { writer.write(Integer.toString(indexNode.getIndex())); - writer.write(copy.getAddress(indexNode).toString()); + writer.write(whirlpoolMixWallet ? copy.getAddress(indexNode).toString().substring(0, 20) + "..." : copy.getAddress(indexNode).toString()); writer.write(getDerivationPath(indexNode)); Optional optLabelEntry = getWalletForm().getNodeEntry(keyPurpose).getChildren().stream() .filter(entry -> ((NodeEntry)entry).getNode().getIndex() == indexNode.getIndex()).findFirst(); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 4442ad61..c0070c13 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -25,6 +25,7 @@ import javafx.application.Platform; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; +import javafx.beans.value.WeakChangeListener; import javafx.collections.ListChangeListener; import javafx.concurrent.Service; import javafx.concurrent.Task; @@ -199,6 +200,10 @@ public class SendController extends WalletFormController implements Initializabl } }; + private final ChangeListener premixButtonOnlineListener = (observable, oldValue, newValue) -> { + premixButton.setDisable(!newValue); + }; + private ValidationSupport validationSupport; private WalletTransactionService walletTransactionService; @@ -385,9 +390,7 @@ public class SendController extends WalletFormController implements Initializabl premixButton.managedProperty().bind(premixButton.visibleProperty()); createButton.visibleProperty().bind(premixButton.visibleProperty().not()); premixButton.setVisible(false); - AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { - premixButton.setDisable(!newValue); - }); + AppServices.onlineProperty().addListener(new WeakChangeListener<>(premixButtonOnlineListener)); } private void initializeTabHeader(int count) { @@ -1054,7 +1057,7 @@ public class SendController extends WalletFormController implements Initializabl public void broadcastPremixUnencrypted(Wallet decryptedWallet) { Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWalletId()); whirlpool.setScode(Config.get().getScode()); - whirlpool.setHDWallet(decryptedWallet); + whirlpool.setHDWallet(getWalletForm().getWalletId(), decryptedWallet); Map utxos = walletTransactionProperty.get().getSelectedUtxos(); Whirlpool.Tx0BroadcastService tx0BroadcastService = new Whirlpool.Tx0BroadcastService(whirlpool, whirlpoolProperty.get(), utxos.keySet()); tx0BroadcastService.setOnRunning(workerStateEvent -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java index e0fa8eea..3803b6a6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java @@ -1,11 +1,15 @@ package com.sparrowwallet.sparrow.wallet; +import com.samourai.whirlpool.client.mix.listener.MixFailReason; +import com.samourai.whirlpool.client.mix.listener.MixStep; +import com.samourai.whirlpool.client.wallet.beans.MixProgress; +import com.samourai.whirlpool.protocol.beans.Utxo; import com.sparrowwallet.drongo.address.Address; -import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; -import com.sparrowwallet.drongo.wallet.Wallet; -import com.sparrowwallet.drongo.wallet.WalletNode; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.BooleanPropertyBase; +import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -32,6 +36,10 @@ public class UtxoEntry extends HashIndexEntry { return false; } + public boolean isMixing() { + return mixStatusProperty != null && ((mixStatusProperty.get().getMixProgress() != null && mixStatusProperty.get().getMixProgress().getMixStep() != MixStep.FAIL) || mixStatusProperty.get().getNextMixUtxo() != null); + } + public Address getAddress() { return getWallet().getAddress(node); } @@ -47,33 +55,129 @@ public class UtxoEntry extends HashIndexEntry { /** * Defines whether this utxo shares it's address with another utxo in the wallet */ - private BooleanProperty duplicateAddress; + private ObjectProperty addressStatusProperty; public final void setDuplicateAddress(boolean value) { - if(duplicateAddress != null || value) { - duplicateAddressProperty().set(value); - } + addressStatusProperty().set(new AddressStatus(value)); } public final boolean isDuplicateAddress() { - return duplicateAddress != null && duplicateAddress.get(); + return addressStatusProperty != null && addressStatusProperty.get().isDuplicate(); } - public final BooleanProperty duplicateAddressProperty() { - if(duplicateAddress == null) { - duplicateAddress = new BooleanPropertyBase(false) { - - @Override - public Object getBean() { - return UtxoEntry.this; - } - - @Override - public String getName() { - return "duplicate"; - } - }; + public final ObjectProperty addressStatusProperty() { + if(addressStatusProperty == null) { + addressStatusProperty = new SimpleObjectProperty<>(UtxoEntry.this, "addressStatus", new AddressStatus(false)); + } + + return addressStatusProperty; + } + + public class AddressStatus { + private final boolean duplicate; + + public AddressStatus(boolean duplicate) { + this.duplicate = duplicate; + } + + public UtxoEntry getUtxoEntry() { + return UtxoEntry.this; + } + + public Address getAddress() { + return UtxoEntry.this.getAddress(); + } + + public boolean isDuplicate() { + return duplicate; + } + } + + /** + * Contains the mix status of this utxo, if available + */ + private ObjectProperty mixStatusProperty; + + public void setMixProgress(MixProgress mixProgress) { + mixStatusProperty().set(new MixStatus(mixProgress)); + } + + public void setMixFailReason(MixFailReason mixFailReason) { + mixStatusProperty().set(new MixStatus(mixFailReason)); + } + + public void setNextMixUtxo(Utxo nextMixUtxo) { + mixStatusProperty().set(new MixStatus(nextMixUtxo)); + } + + public final MixStatus getMixStatus() { + return mixStatusProperty == null ? null : mixStatusProperty.get(); + } + + public final ObjectProperty mixStatusProperty() { + if(mixStatusProperty == null) { + mixStatusProperty = new SimpleObjectProperty<>(UtxoEntry.this, "mixStatus", null); + } + + return mixStatusProperty; + } + + public class MixStatus { + private MixProgress mixProgress; + private Utxo nextMixUtxo; + private MixFailReason mixFailReason; + + public MixStatus(MixProgress mixProgress) { + this.mixProgress = mixProgress; + } + + public MixStatus(Utxo nextMixUtxo) { + this.nextMixUtxo = nextMixUtxo; + } + + public MixStatus(MixFailReason mixFailReason) { + this.mixFailReason = mixFailReason; + } + + public UtxoEntry getUtxoEntry() { + return UtxoEntry.this; + } + + public UtxoMixData getUtxoMixData() { + Wallet wallet = getUtxoEntry().getWallet().getMasterWallet(); + if(wallet.getUtxoMixData(getHashIndex()) != null) { + return wallet.getUtxoMixData(getHashIndex()); + } + + Whirlpool whirlpool = AppServices.get().getWhirlpool(wallet); + if(whirlpool != null) { + UtxoMixData utxoMixData = whirlpool.getMixData(getHashIndex()); + if(utxoMixData != null) { + return utxoMixData; + } + } + + return new UtxoMixData("Unknown Pool", getUtxoEntry().getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX ? 1 : 0, null); + } + + public int getMixesDone() { + return getUtxoMixData().getMixesDone(); + } + + public String getPoolId() { + return getUtxoMixData().getPoolId(); + } + + public MixProgress getMixProgress() { + return mixProgress; + } + + public Utxo getNextMixUtxo() { + return nextMixUtxo; + } + + public MixFailReason getMixFailReason() { + return mixFailReason; } - return duplicateAddress; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java index 3538e6d2..82bfd0f3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java @@ -15,14 +15,18 @@ import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; import com.sparrowwallet.sparrow.whirlpool.WhirlpoolDialog; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.WeakChangeListener; import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.control.Button; import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; import javafx.stage.FileChooser; import javafx.stage.Stage; import org.slf4j.Logger; @@ -42,6 +46,15 @@ public class UtxosController extends WalletFormController implements Initializab @FXML private UtxosTreeTable utxosTable; + @FXML + private HBox mixButtonsBox; + + @FXML + private Button startMix; + + @FXML + private Button stopMix; + @FXML private Button sendSelected; @@ -51,6 +64,12 @@ public class UtxosController extends WalletFormController implements Initializab @FXML private UtxosChart utxosChart; + private final ChangeListener mixingOnlineListener = (observable, oldValue, newValue) -> { + mixSelected.setDisable(getSelectedEntries().isEmpty() || !newValue); + startMix.setDisable(!newValue); + stopMix.setDisable(!newValue); + }; + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -60,14 +79,30 @@ public class UtxosController extends WalletFormController implements Initializab public void initializeView() { utxosTable.initialize(getWalletForm().getWalletUtxosEntry()); utxosChart.initialize(getWalletForm().getWalletUtxosEntry()); + + mixButtonsBox.managedProperty().bind(mixButtonsBox.visibleProperty()); + mixButtonsBox.setVisible(getWalletForm().getWallet().isWhirlpoolMixWallet()); + startMix.managedProperty().bind(startMix.visibleProperty()); + startMix.setDisable(!AppServices.isConnected()); + stopMix.managedProperty().bind(stopMix.visibleProperty()); + startMix.visibleProperty().bind(stopMix.visibleProperty().not()); + stopMix.visibleProperty().addListener((observable, oldValue, newValue) -> { + stopMix.setDisable(!newValue); + startMix.setDisable(newValue); + }); + if(mixButtonsBox.isVisible()) { + Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool != null) { + stopMix.visibleProperty().bind(whirlpool.mixingProperty()); + } + } + sendSelected.setDisable(true); sendSelected.setTooltip(new Tooltip("Send selected UTXOs. Use " + (org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? "Cmd" : "Ctrl") + "+click to select multiple." )); mixSelected.managedProperty().bind(mixSelected.visibleProperty()); mixSelected.setVisible(canWalletMix()); mixSelected.setDisable(true); - AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { - mixSelected.setDisable(getSelectedEntries().isEmpty() || !newValue); - }); + AppServices.onlineProperty().addListener(new WeakChangeListener<>(mixingOnlineListener)); utxosTable.getSelectionModel().getSelectedIndices().addListener((ListChangeListener) c -> { List selectedEntries = utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue()).collect(Collectors.toList()); @@ -76,11 +111,11 @@ public class UtxosController extends WalletFormController implements Initializab }); utxosChart.managedProperty().bind(utxosChart.visibleProperty()); - utxosChart.setVisible(Config.get().isShowUtxosChart()); + utxosChart.setVisible(Config.get().isShowUtxosChart() && !getWalletForm().getWallet().isWhirlpoolMixWallet()); } private boolean canWalletMix() { - return Network.get() == Network.TESTNET && getWalletForm().getWallet().getKeystores().size() == 1 && getWalletForm().getWallet().getKeystores().get(0).hasSeed(); + return Network.get() == Network.TESTNET && getWalletForm().getWallet().getKeystores().size() == 1 && getWalletForm().getWallet().getKeystores().get(0).hasSeed() && !getWalletForm().getWallet().isWhirlpoolMixWallet(); } private void updateButtons(BitcoinUnit unit) { @@ -104,13 +139,13 @@ public class UtxosController extends WalletFormController implements Initializab } } else { sendSelected.setText("Send Selected"); - sendSelected.setText("Mix Selected"); + mixSelected.setText("Mix Selected"); } } private List getSelectedEntries() { - return utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue()) - .filter(entry -> ((HashIndexEntry)entry).isSpendable()) + return utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> (UtxoEntry)tp.getTreeItem().getValue()) + .filter(utxoEntry -> utxoEntry.isSpendable() && !utxoEntry.isMixing()) .collect(Collectors.toList()); } @@ -180,6 +215,39 @@ public class UtxosController extends WalletFormController implements Initializab utxosTable.getSelectionModel().clearSelection(); } + public void startMixing(ActionEvent event) { + startMix.setDisable(true); + stopMix.setDisable(false); + + Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool != null && !whirlpool.isStarted() && AppServices.isConnected()) { + Whirlpool.StartupService startupService = new Whirlpool.StartupService(whirlpool); + startupService.setOnFailed(workerStateEvent -> { + AppServices.showErrorDialog("Failed to start whirlpool", workerStateEvent.getSource().getException().getMessage()); + log.error("Failed to start whirlpool", workerStateEvent.getSource().getException()); + }); + startupService.start(); + } + } + + public void stopMixing(ActionEvent event) { + stopMix.setDisable(true); + startMix.setDisable(false); + + Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet()); + if(whirlpool.isStarted()) { + Whirlpool.ShutdownService shutdownService = new Whirlpool.ShutdownService(whirlpool); + shutdownService.setOnFailed(workerStateEvent -> { + log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException()); + AppServices.showErrorDialog("Failed to stop whirlpool", workerStateEvent.getSource().getException().getMessage()); + }); + shutdownService.start(); + } else { + //Ensure http clients are shutdown + whirlpool.shutdown(); + } + } + public void exportUtxos(ActionEvent event) { Stage window = new Stage(); @@ -297,6 +365,25 @@ public class UtxosController extends WalletFormController implements Initializab @Subscribe public void utxosChartChanged(UtxosChartChangedEvent event) { - utxosChart.setVisible(event.isVisible()); + utxosChart.setVisible(event.isVisible() && !getWalletForm().getWallet().isWhirlpoolMixWallet()); + } + + @Subscribe + public void whirlpoolMix(WhirlpoolMixEvent event) { + if(event.getWallet().equals(walletForm.getWallet())) { + WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); + for(Entry entry : walletUtxosEntry.getChildren()) { + UtxoEntry utxoEntry = (UtxoEntry)entry; + if(utxoEntry.getHashIndex().equals(event.getUtxo())) { + if(event.getNextUtxo() != null) { + utxoEntry.setNextMixUtxo(event.getNextUtxo()); + } else if(event.getMixFailReason() != null) { + utxoEntry.setMixFailReason(event.getMixFailReason()); + } else { + utxoEntry.setMixProgress(event.getMixProgress()); + } + } + } + } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java index fd61fc0e..e5133982 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow.wallet; import com.google.common.eventbus.Subscribe; +import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.ReceiveActionEvent; @@ -72,10 +73,13 @@ public class WalletController extends WalletFormController implements Initializa } }); - configure(walletForm.getWallet().isValid()); + configure(walletForm.getWallet()); } - public void configure(boolean validWallet) { + public void configure(Wallet wallet) { + boolean validWallet = wallet.isValid(); + boolean whirlpoolMixWallet = wallet.isWhirlpoolMixWallet(); + for(Toggle toggle : walletMenu.getToggles()) { if(toggle.getUserData().equals(Function.SETTINGS)) { if(!validWallet) { @@ -86,7 +90,7 @@ public class WalletController extends WalletFormController implements Initializa toggle.setSelected(true); } - ((ToggleButton)toggle).setDisable(!validWallet); + ((ToggleButton)toggle).setDisable(!validWallet || (whirlpoolMixWallet && toggle.getUserData().equals(Function.RECEIVE))); } } } @@ -104,7 +108,7 @@ public class WalletController extends WalletFormController implements Initializa @Subscribe public void walletAddressesChanged(WalletAddressesChangedEvent event) { if(event.getWalletId().equals(walletForm.getWalletId())) { - configure(event.getWallet().isValid()); + configure(event.getWallet()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java index 70581c60..a7d601c4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java @@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow.wallet; import com.google.common.eventbus.Subscribe; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.protocol.Sha256Hash; -import com.sparrowwallet.drongo.wallet.BlockTransaction; -import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; -import com.sparrowwallet.drongo.wallet.Wallet; -import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.WalletTabData; @@ -17,6 +14,7 @@ import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.net.ServerType; import javafx.application.Platform; +import javafx.util.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,6 +35,8 @@ public class WalletForm { private final List accountEntries = new ArrayList<>(); private final List> walletTransactionNodes = new ArrayList<>(); + private ElectrumServer.TransactionMempoolService transactionMempoolService; + public WalletForm(Storage storage, Wallet currentWallet, Wallet backupWallet) { this(storage, currentWallet, backupWallet, true); } @@ -146,6 +146,7 @@ public class WalletForm { Set labelChangedEntries = Collections.emptySet(); if(pastWallet != null) { labelChangedEntries = copyLabels(pastWallet); + copyMixData(pastWallet); } notifyIfChanged(blockHeight, previousWallet, labelChangedEntries); @@ -156,14 +157,13 @@ public class WalletForm { //On a full wallet refresh, walletUtxosEntry and walletTransactionsEntry will have no children yet, but AddressesController may have created accountEntries on a walletNodesChangedEvent //Copy nodeEntry labels - List keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE); - for(KeyPurpose keyPurpose : keyPurposes) { + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { NodeEntry purposeEntry = getNodeEntry(keyPurpose); changedEntries.addAll(purposeEntry.copyLabels(pastWallet.getNode(purposeEntry.getNode().getKeyPurpose()))); } //Copy node and txo labels - for(KeyPurpose keyPurpose : keyPurposes) { + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { if(wallet.getNode(keyPurpose).copyLabels(pastWallet.getNode(keyPurpose))) { changedEntries.add(getWalletUtxosEntry()); } @@ -182,6 +182,10 @@ public class WalletForm { return changedEntries; } + private void copyMixData(Wallet pastWallet) { + wallet.getUtxoMixes().forEach(pastWallet.getUtxoMixes()::putIfAbsent); + } + private void notifyIfChanged(Integer blockHeight, Wallet previousWallet, Set labelChangedEntries) { List historyChangedNodes = new ArrayList<>(); historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), wallet.getNode(KeyPurpose.RECEIVE).getChildren())); @@ -361,6 +365,10 @@ public class WalletForm { @Subscribe public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) { if(wallet.isValid()) { + if(transactionMempoolService != null) { + transactionMempoolService.cancel(); + } + WalletNode walletNode = event.getWalletNode(wallet); if(walletNode != null) { log.debug(wallet.getFullName() + " history event for node " + walletNode + " (" + event.getScriptHash() + ")"); @@ -382,7 +390,7 @@ public class WalletForm { changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap())); } - if(receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) { + if((receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) && wallet.getStandardAccountType() != StandardAccount.WHIRLPOOL_PREMIX) { receivedRef.setLabel(changedNode.getLabel() + (changedNode.getKeyPurpose() == KeyPurpose.CHANGE ? " (change)" : " (received)")); changedLabelEntries.add(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, changedNode.getKeyPurpose())); } @@ -404,12 +412,11 @@ public class WalletForm { if(entry.getLabel() != null && !entry.getLabel().isEmpty()) { if(entry instanceof TransactionEntry) { TransactionEntry transactionEntry = (TransactionEntry)entry; - List keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE); - for(KeyPurpose keyPurpose : keyPurposes) { + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { for(WalletNode childNode : wallet.getNode(keyPurpose).getChildren()) { for(BlockTransactionHashIndex receivedRef : childNode.getTransactionOutputs()) { if(receivedRef.getHash().equals(transactionEntry.getBlockTransaction().getHash())) { - if(receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) { + if((receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) && wallet.getStandardAccountType() != StandardAccount.WHIRLPOOL_PREMIX) { receivedRef.setLabel(entry.getLabel() + (keyPurpose == KeyPurpose.CHANGE ? " (change)" : " (received)")); labelChangedEntries.add(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, keyPurpose)); } @@ -462,6 +469,38 @@ public class WalletForm { } } + @Subscribe + public void walletUtxoMixesChanged(WalletUtxoMixesChangedEvent event) { + if(event.getWallet() == wallet) { + Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet))); + } + } + + @Subscribe + public void whirlpoolMixSuccess(WhirlpoolMixSuccessEvent event) { + if(event.getWallet() == wallet && event.getWalletNode() != null) { + if(transactionMempoolService != null) { + transactionMempoolService.cancel(); + } + + transactionMempoolService = new ElectrumServer.TransactionMempoolService(event.getWallet(), Sha256Hash.wrap(event.getNextUtxo().getHash()), Set.of(event.getWalletNode())); + transactionMempoolService.setDelay(Duration.seconds(5)); + transactionMempoolService.setPeriod(Duration.seconds(5)); + transactionMempoolService.setRestartOnFailure(false); + transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> { + Set scriptHashes = transactionMempoolService.getValue(); + if(!scriptHashes.isEmpty()) { + Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHashes.iterator().next()))); + } + + if(transactionMempoolService.getIterationCount() > 10) { + transactionMempoolService.cancel(); + } + }); + transactionMempoolService.start(); + } + } + @Subscribe public void walletTabsClosed(WalletTabsClosedEvent event) { for(WalletTabData tabData : event.getClosedWalletTabData()) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java index ac31be43..6de256c7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java @@ -69,10 +69,7 @@ public class WalletTransactionsEntry extends Entry { List entriesComplete = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).isComplete()).collect(Collectors.toList()); if(!entriesComplete.isEmpty()) { - List blockTransactions = entriesAdded.stream().map(txEntry -> ((TransactionEntry)txEntry).getBlockTransaction()).collect(Collectors.toList()); - long totalBlockchainValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() > 0).mapToLong(Entry::getValue).sum(); - long totalMempoolValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() == 0).mapToLong(Entry::getValue).sum(); - EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), blockTransactions, totalBlockchainValue, totalMempoolValue)); + EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), entriesAdded.stream().map(entry -> (TransactionEntry)entry).collect(Collectors.toList()))); } if(entriesAdded.size() > entriesComplete.size()) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java index 6d61a64b..c243c883 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java @@ -1,6 +1,9 @@ package com.sparrowwallet.sparrow.wallet; +import com.samourai.whirlpool.client.wallet.beans.MixProgress; import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; import java.util.*; import java.util.stream.Collectors; @@ -9,6 +12,7 @@ public class WalletUtxosEntry extends Entry { public WalletUtxosEntry(Wallet wallet) { super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList())); calculateDuplicates(); + retrieveMixProgress(); } @Override @@ -34,6 +38,17 @@ public class WalletUtxosEntry extends Entry { } } + protected void retrieveMixProgress() { + Whirlpool whirlpool = AppServices.get().getWhirlpool(getWallet()); + if(whirlpool != null) { + for(Entry entry : getChildren()) { + UtxoEntry utxoEntry = (UtxoEntry)entry; + MixProgress mixProgress = whirlpool.getMixProgress(utxoEntry.getHashIndex()); + utxoEntry.setMixProgress(mixProgress); + } + } + } + public void updateUtxos() { List current = getWallet().getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()); List previous = new ArrayList<>(getChildren()); @@ -47,5 +62,6 @@ public class WalletUtxosEntry extends Entry { getChildren().removeAll(entriesRemoved); calculateDuplicates(); + retrieveMixProgress(); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowDataSource.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowDataSource.java index 1c42d1c2..fab9b4ad 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowDataSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowDataSource.java @@ -226,12 +226,12 @@ public class SparrowDataSource extends WalletResponseDataSource { private Wallet getWallet(String zpub) { return AppServices.get().getOpenWallets().keySet().stream() + .filter(Wallet::isValid) .filter(wallet -> { List headers = ExtendedKey.Header.getHeaders(Network.get()); ExtendedKey.Header header = headers.stream().filter(head -> head.getDefaultScriptType().equals(wallet.getScriptType()) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); - ExtendedKey.Header p2pkhHeader = headers.stream().filter(head -> head.getDefaultScriptType().equals(ScriptType.P2PKH) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); ExtendedKey extPubKey = wallet.getKeystores().get(0).getExtendedPublicKey(); - return extPubKey.toString(header).equals(zpub) || extPubKey.toString(p2pkhHeader).equals(zpub); + return extPubKey.toString(header).equals(zpub); }) .findFirst() .orElse(null); diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowUtxoConfigPersister.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowUtxoConfigPersister.java new file mode 100644 index 00000000..8e24950b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowUtxoConfigPersister.java @@ -0,0 +1,77 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.google.common.collect.MapDifference; +import com.google.common.collect.Maps; +import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigData; +import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersisted; +import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersister; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.wallet.UtxoMixData; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WalletUtxoMixesChangedEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +public class SparrowUtxoConfigPersister extends UtxoConfigPersister { + private static final Logger log = LoggerFactory.getLogger(SparrowUtxoConfigPersister.class); + + private final String walletId; + private long lastWrite; + + public SparrowUtxoConfigPersister(String walletId) { + super(walletId); + this.walletId = walletId; + } + + @Override + public synchronized UtxoConfigData load() throws Exception { + Wallet wallet = getWallet(); + if(wallet == null) { + throw new IllegalStateException("Can't find wallet with walletId " + walletId); + } + + Map utxoConfigs = wallet.getUtxoMixes().entrySet().stream() + .collect(Collectors.toMap(entry -> entry.getKey().toString(), entry -> new UtxoConfigPersisted(entry.getValue().getPoolId(), entry.getValue().getMixesDone(), entry.getValue().getForwarding()), + (u, v) -> { throw new IllegalStateException("Duplicate utxo config hashes"); }, + HashMap::new)); + + return new UtxoConfigData(utxoConfigs); + } + + @Override + public synchronized void write(UtxoConfigData data) throws Exception { + Wallet wallet = getWallet(); + if(wallet == null) { + //Wallet is already closed + return; + } + + Map currentData = new HashMap<>(data.getUtxoConfigs()); + Map changedUtxoMixes = currentData.entrySet().stream() + .collect(Collectors.toMap(entry -> Sha256Hash.wrap(entry.getKey()), entry -> new UtxoMixData(entry.getValue().getPoolId(), entry.getValue().getMixsDone(), entry.getValue().getForwarding()), + (u, v) -> { throw new IllegalStateException("Duplicate utxo config hashes"); }, + HashMap::new)); + + MapDifference mapDifference = Maps.difference(changedUtxoMixes, wallet.getUtxoMixes()); + Map removedUtxoMixes = mapDifference.entriesOnlyOnRight(); + wallet.getUtxoMixes().putAll(changedUtxoMixes); + wallet.getUtxoMixes().keySet().removeAll(removedUtxoMixes.keySet()); + + EventManager.get().post(new WalletUtxoMixesChangedEvent(wallet, changedUtxoMixes, removedUtxoMixes)); + lastWrite = System.currentTimeMillis(); + } + + private Wallet getWallet() { + return AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElse(null); + } + + @Override + public long getLastWrite() { + return lastWrite; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletDataSupplier.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletDataSupplier.java new file mode 100644 index 00000000..ed4548f1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletDataSupplier.java @@ -0,0 +1,24 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.wallet.hd.HD_Wallet; +import com.samourai.whirlpool.client.wallet.WhirlpoolWalletConfig; +import com.samourai.whirlpool.client.wallet.data.minerFee.BackendWalletDataSupplier; +import com.samourai.whirlpool.client.wallet.data.minerFee.WalletSupplier; +import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersister; + +public class SparrowWalletDataSupplier extends BackendWalletDataSupplier { + public SparrowWalletDataSupplier(int refreshUtxoDelay, WhirlpoolWalletConfig config, HD_Wallet bip44w, String walletIdentifier) throws Exception { + super(refreshUtxoDelay, config, bip44w, walletIdentifier); + } + + @Override + protected WalletSupplier computeWalletSupplier(WhirlpoolWalletConfig config, HD_Wallet bip44w, String walletIdentifier) throws Exception { + int externalIndexDefault = config.getExternalDestination() != null ? config.getExternalDestination().getStartIndex() : 0; + return new WalletSupplier(new SparrowWalletStatePersister(walletIdentifier), config.getBackendApi(), bip44w, externalIndexDefault); + } + + @Override + protected UtxoConfigPersister computeUtxoConfigPersister(String walletIdentifier) throws Exception { + return new SparrowUtxoConfigPersister(walletIdentifier); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletStatePersister.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletStatePersister.java new file mode 100644 index 00000000..3f68dd8a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletStatePersister.java @@ -0,0 +1,53 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.whirlpool.client.wallet.data.walletState.WalletStateData; +import com.samourai.whirlpool.client.wallet.data.walletState.WalletStatePersister; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.StandardAccount; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class SparrowWalletStatePersister extends WalletStatePersister { + private final String walletId; + + public SparrowWalletStatePersister(String walletId) { + super(walletId); + this.walletId = walletId; + } + + @Override + public synchronized WalletStateData load() throws Exception { + Wallet wallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElseThrow(); + + Map values = new LinkedHashMap<>(); + values.put("init", 1); + putValues("DEPOSIT", wallet, values); + + for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) { + putValues(whirlpoolAccount.getName().toUpperCase(), wallet.getChildWallet(whirlpoolAccount), values); + } + + return new WalletStateData(values); + } + + private void putValues(String prefix, Wallet wallet, Map values) { + for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) { + Integer index = wallet.getNode(keyPurpose).getHighestUsedIndex(); + values.put(prefix + "_" + getPurpose(wallet) + "_" + keyPurpose.getPathIndex().num(), index == null ? 0 : index + 1); + } + } + + private int getPurpose(Wallet wallet) { + ScriptType scriptType = wallet.getScriptType(); + return scriptType.getDefaultDerivation().get(0).num(); + } + + @Override + public synchronized void write(WalletStateData data) throws Exception { + //nothing required + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWhirlpoolWalletService.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWhirlpoolWalletService.java new file mode 100644 index 00000000..2d575911 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWhirlpoolWalletService.java @@ -0,0 +1,23 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.wallet.hd.HD_Wallet; +import com.samourai.whirlpool.client.wallet.WhirlpoolWalletConfig; +import com.samourai.whirlpool.client.wallet.WhirlpoolWalletService; +import com.samourai.whirlpool.client.wallet.data.minerFee.WalletDataSupplier; + +public class SparrowWhirlpoolWalletService extends WhirlpoolWalletService { + private String walletId; + + @Override + protected WalletDataSupplier computeWalletDataSupplier(WhirlpoolWalletConfig config, HD_Wallet bip44w, String walletIdentifier) throws Exception { + return new SparrowWalletDataSupplier(config.getRefreshUtxoDelay(), config, bip44w, walletId); + } + + public String getWalletId() { + return walletId; + } + + public void setWalletId(String walletId) { + this.walletId = walletId; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java index 496c750d..f371a003 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java @@ -14,19 +14,17 @@ import com.samourai.whirlpool.client.tx0.*; import com.samourai.whirlpool.client.wallet.WhirlpoolEventService; import com.samourai.whirlpool.client.wallet.WhirlpoolWallet; import com.samourai.whirlpool.client.wallet.WhirlpoolWalletConfig; -import com.samourai.whirlpool.client.wallet.WhirlpoolWalletService; -import com.samourai.whirlpool.client.wallet.beans.Tx0FeeTarget; -import com.samourai.whirlpool.client.wallet.beans.WhirlpoolAccount; -import com.samourai.whirlpool.client.wallet.beans.WhirlpoolServer; -import com.samourai.whirlpool.client.wallet.beans.WhirlpoolUtxo; +import com.samourai.whirlpool.client.wallet.beans.*; import com.samourai.whirlpool.client.wallet.data.dataPersister.DataPersisterFactory; import com.samourai.whirlpool.client.wallet.data.dataPersister.FileDataPersister; import com.samourai.whirlpool.client.wallet.data.dataSource.DataSourceFactory; import com.samourai.whirlpool.client.wallet.data.pool.PoolData; +import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersisted; import com.samourai.whirlpool.client.wallet.data.utxo.UtxoSupplier; import com.samourai.whirlpool.client.whirlpool.ServerApi; import com.samourai.whirlpool.client.whirlpool.beans.Pool; import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.protocol.ScriptType; @@ -38,7 +36,13 @@ import com.sparrowwallet.nightjar.http.JavaHttpClientService; import com.sparrowwallet.nightjar.stomp.JavaStompClientService; import com.sparrowwallet.nightjar.tor.WhirlpoolTorClientService; import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WhirlpoolMixEvent; +import com.sparrowwallet.sparrow.event.WhirlpoolMixSuccessEvent; import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.concurrent.Service; import javafx.concurrent.Task; import org.slf4j.Logger; @@ -57,10 +61,12 @@ public class Whirlpool { private final JavaHttpClientService httpClientService; private final JavaStompClientService stompClientService; private final TorClientService torClientService; - private final WhirlpoolWalletService whirlpoolWalletService; + private final SparrowWhirlpoolWalletService whirlpoolWalletService; private final WhirlpoolWalletConfig config; private HD_Wallet hdWallet; + private BooleanProperty mixingProperty = new SimpleBooleanProperty(false); + public Whirlpool(Network network, HostAndPort torProxy, String sCode, int maxClients) { this.torProxy = torProxy; this.whirlpoolServer = WhirlpoolServer.valueOf(network.getName().toUpperCase()); @@ -70,7 +76,7 @@ public class Whirlpool { DataPersisterFactory dataPersisterFactory = (config, bip44w, walletIdentifier) -> new FileDataPersister(config, bip44w, walletIdentifier); DataSourceFactory dataSourceFactory = (config, bip44w, walletIdentifier, dataPersister) -> new SparrowDataSource(config, bip44w, walletIdentifier, dataPersister); - this.whirlpoolWalletService = new WhirlpoolWalletService(dataPersisterFactory, dataSourceFactory); + this.whirlpoolWalletService = new SparrowWhirlpoolWalletService(dataPersisterFactory, dataSourceFactory); this.config = computeWhirlpoolWalletConfig(sCode, maxClients); WhirlpoolEventService.getInstance().register(this); @@ -134,7 +140,7 @@ public class Whirlpool { return null; } - public void setHDWallet(Wallet wallet) { + public void setHDWallet(String walletId, Wallet wallet) { if(wallet.isEncrypted()) { throw new IllegalStateException("Wallet cannot be encrypted"); } @@ -147,6 +153,7 @@ public class Whirlpool { String passphrase = keystore.getSeed().getPassphrase().asString(); HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance(); byte[] seed = hdWalletFactory.computeSeedFromWords(words); + whirlpoolWalletService.setWalletId(walletId); hdWallet = new HD_Wallet(purpose, words, config.getNetworkParameters(), seed, passphrase, 1); } catch(Exception e) { throw new IllegalStateException("Could not create Whirlpool HD wallet ", e); @@ -169,6 +176,76 @@ public class Whirlpool { } } + public void stop() { + if(whirlpoolWalletService.whirlpoolWallet() != null) { + whirlpoolWalletService.whirlpoolWallet().stop(); + } + } + + public UtxoMixData getMixData(BlockTransactionHashIndex txo) { + if(whirlpoolWalletService.whirlpoolWallet() != null) { + UtxoConfigPersisted config = whirlpoolWalletService.whirlpoolWallet().getUtxoConfigSupplier().getUtxoConfigPersisted(txo.getHashAsString(), (int)txo.getIndex()); + if(config != null) { + return new UtxoMixData(config.getPoolId(), config.getMixsDone(), config.getForwarding()); + } + } + + return null; + } + + private void persistMixData() { + try { + whirlpoolWalletService.whirlpoolWallet().getUtxoConfigSupplier().persist(true); + } catch(Exception e) { + log.error("Error persisting mix data", e); + } + } + + public void mix(BlockTransactionHashIndex utxo) throws WhirlpoolException { + if(whirlpoolWalletService.whirlpoolWallet() == null) { + throw new WhirlpoolException("Whirlpool wallet not yet created"); + } + + try { + WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex()); + whirlpoolWalletService.whirlpoolWallet().mixNow(whirlpoolUtxo); + } catch(Exception e) { + throw new WhirlpoolException(e.getMessage(), e); + } + } + + public void mixStop(BlockTransactionHashIndex utxo) throws WhirlpoolException { + if(whirlpoolWalletService.whirlpoolWallet() == null) { + throw new WhirlpoolException("Whirlpool wallet not yet created"); + } + + try { + WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex()); + whirlpoolWalletService.whirlpoolWallet().mixStop(whirlpoolUtxo); + } catch(Exception e) { + throw new WhirlpoolException(e.getMessage(), e); + } + } + + public MixProgress getMixProgress(BlockTransactionHashIndex utxo) { + if(whirlpoolWalletService.whirlpoolWallet() == null) { + return null; + } + + WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex()); + if(whirlpoolUtxo != null && whirlpoolUtxo.getUtxoState() != null) { + return whirlpoolUtxo.getUtxoState().getMixProgress(); + } + + return null; + } + + public void refreshUtxos() { + if(whirlpoolWalletService.whirlpoolWallet() != null) { + whirlpoolWalletService.whirlpoolWallet().refreshUtxos(); + } + } + public HostAndPort getTorProxy() { return torProxy; } @@ -190,6 +267,36 @@ public class Whirlpool { httpClientService.shutdown(); } + private WalletUtxo getUtxo(WhirlpoolUtxo whirlpoolUtxo) { + Wallet wallet = AppServices.get().getWallet(whirlpoolWalletService.getWalletId()); + if(wallet != null) { + StandardAccount standardAccount = getStandardAccount(whirlpoolUtxo.getAccount()); + if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) { + wallet = wallet.getChildWallet(standardAccount); + } + + for(BlockTransactionHashIndex utxo : wallet.getWalletUtxos().keySet()) { + if(utxo.getHashAsString().equals(whirlpoolUtxo.getUtxo().tx_hash) && utxo.getIndex() == whirlpoolUtxo.getUtxo().tx_output_n) { + return new WalletUtxo(wallet, utxo); + } + } + } + + return null; + } + + public static StandardAccount getStandardAccount(WhirlpoolAccount whirlpoolAccount) { + if(whirlpoolAccount == WhirlpoolAccount.PREMIX) { + return StandardAccount.WHIRLPOOL_PREMIX; + } else if(whirlpoolAccount == WhirlpoolAccount.POSTMIX) { + return StandardAccount.WHIRLPOOL_POSTMIX; + } else if(whirlpoolAccount == WhirlpoolAccount.BADBANK) { + return StandardAccount.WHIRLPOOL_BADBANK; + } + + return StandardAccount.ACCOUNT_0; + } + public static UnspentOutput getUnspentOutput(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) { TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get(index); @@ -235,24 +342,64 @@ public class Whirlpool { config.setScode(scode); } - @Subscribe - public void onMixFail(MixFailEvent e) { - log.info("Mix failed for utxo " + e.getMixFail().getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getMixFail().getWhirlpoolUtxo().getUtxo().tx_output_n); + public boolean isMixing() { + return mixingProperty.get(); + } + + public BooleanProperty mixingProperty() { + return mixingProperty; } @Subscribe public void onMixSuccess(MixSuccessEvent e) { - log.info("Mix success, new utxo " + e.getMixSuccess().getReceiveUtxo().getHash() + ":" + e.getMixSuccess().getReceiveUtxo().getIndex()); + WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo()); + if(walletUtxo != null) { + log.debug("Mix success, new utxo " + e.getMixSuccess().getReceiveUtxo().getHash() + ":" + e.getMixSuccess().getReceiveUtxo().getIndex()); + persistMixData(); + Platform.runLater(() -> EventManager.get().post(new WhirlpoolMixSuccessEvent(walletUtxo.wallet, walletUtxo.utxo, e.getMixSuccess().getReceiveUtxo(), getReceiveNode(e, walletUtxo)))); + } + } + + private WalletNode getReceiveNode(MixSuccessEvent e, WalletUtxo walletUtxo) { + for(WalletNode walletNode : walletUtxo.wallet.getNode(KeyPurpose.RECEIVE).getChildren()) { + if(walletUtxo.wallet.getAddress(walletNode).toString().equals(e.getMixSuccess().getReceiveAddress())) { + return walletNode; + } + } + + return null; + } + + @Subscribe + public void onMixFail(MixFailEvent e) { + WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo()); + if(walletUtxo != null) { + log.debug("Mix failed for utxo " + e.getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getWhirlpoolUtxo().getUtxo().tx_output_n + " " + e.getMixFailReason()); + Platform.runLater(() -> EventManager.get().post(new WhirlpoolMixEvent(walletUtxo.wallet, walletUtxo.utxo, e.getMixFailReason()))); + } + } + + @Subscribe + public void onMixProgress(MixProgressEvent e) { + WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo()); + if(walletUtxo != null && isMixing()) { + log.debug("Mix progress for utxo " + e.getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getWhirlpoolUtxo().getUtxo().tx_output_n + " " + e.getWhirlpoolUtxo().getMixsDone() + " " + e.getMixProgress().getMixStep() + " " + e.getWhirlpoolUtxo().getUtxoState().getStatus()); + Platform.runLater(() -> EventManager.get().post(new WhirlpoolMixEvent(walletUtxo.wallet, walletUtxo.utxo, e.getMixProgress()))); + } } @Subscribe public void onWalletStart(WalletStartEvent e) { - log.info("Wallet started"); + if(e.getWhirlpoolWallet() == whirlpoolWalletService.whirlpoolWallet()) { + mixingProperty.set(true); + } } @Subscribe public void onWalletStop(WalletStopEvent e) { - log.info("Wallet stopped"); + if(e.getWhirlpoolWallet() == whirlpoolWalletService.whirlpoolWallet()) { + mixingProperty.set(false); + } } public static class PoolsService extends Service> { @@ -369,4 +516,14 @@ public class Whirlpool { }; } } + + public static class WalletUtxo { + public final Wallet wallet; + public final BlockTransactionHashIndex utxo; + + public WalletUtxo(Wallet wallet, BlockTransactionHashIndex utxo) { + this.wallet = wallet; + this.utxo = utxo; + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java index 91aa1cb1..fd828c65 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java @@ -94,6 +94,11 @@ public class WhirlpoolController { Config.get().setScode(newValue); }); + if(Config.get().getScode() != null) { + step1.setVisible(false); + step3.setVisible(true); + } + pool.setConverter(new StringConverter() { @Override public String toString(Pool pool) { @@ -219,6 +224,10 @@ public class WhirlpoolController { } private void fetchTx0Preview(Pool pool) { + if(Config.get().getScode() == null) { + Config.get().setScode(""); + } + Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId); whirlpool.setScode(Config.get().getScode()); diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java index 22dfbf15..103d13f4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java @@ -1,9 +1,9 @@ package com.sparrowwallet.sparrow.whirlpool; import com.samourai.whirlpool.client.tx0.Tx0Preview; -import com.samourai.whirlpool.client.whirlpool.beans.Pool; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.wallet.UtxoEntry; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; @@ -27,9 +27,10 @@ public class WhirlpoolDialog extends Dialog { whirlpoolController.initializeView(walletId, wallet, utxoEntries); dialogPane.setPrefWidth(600); - dialogPane.setPrefHeight(520); + dialogPane.setPrefHeight(550); AppServices.moveToActiveWindowScreen(this); + dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm()); dialogPane.getStylesheets().add(AppServices.class.getResource("whirlpool/whirlpool.css").toExternalForm()); final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE); @@ -53,7 +54,9 @@ public class WhirlpoolDialog extends Dialog { backButton.managedProperty().bind(backButton.visibleProperty()); previewButton.managedProperty().bind(previewButton.visibleProperty()); - backButton.setDisable(true); + if(Config.get().getScode() == null) { + backButton.setDisable(true); + } previewButton.visibleProperty().bind(nextButton.visibleProperty().not()); nextButton.addEventFilter(ActionEvent.ACTION, event -> { diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.css b/src/main/resources/com/sparrowwallet/sparrow/app.css index 10e5eb5f..8c0f42c2 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.css +++ b/src/main/resources/com/sparrowwallet/sparrow/app.css @@ -32,6 +32,19 @@ visibility: hidden; } +.wallet-subtabs > .tab-header-area .tab { + -fx-pref-height: 50; + -fx-pref-width: 80; + -fx-alignment: CENTER; +} + +.wallet-subtabs > .tab-header-area .tab-label { + -fx-pref-height: 50; + -fx-pref-width: 80; + -fx-alignment: CENTER; + -fx-translate-x: -6; +} + .status-bar .status-label { -fx-alignment: center-left; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index 4e066a8a..2944267c 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -10,7 +10,7 @@ - + diff --git a/src/main/resources/com/sparrowwallet/sparrow/sql/V2__Whirlpool.sql b/src/main/resources/com/sparrowwallet/sparrow/sql/V2__Whirlpool.sql new file mode 100644 index 00000000..aa34a95f --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/sql/V2__Whirlpool.sql @@ -0,0 +1 @@ +create table utxoMixData (id identity not null, hash binary(32) not null, poolId varchar(32), mixesDone integer not null default 0, forwarding bigint, wallet bigint not null); diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.css index 5c2fa481..f2eae55b 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.css @@ -4,6 +4,10 @@ -fx-padding: 10 0 10 0; } +.utxos-treetable .progress-bar > .bar { + -fx-padding: 0.6em; +} + .utxos-buttons-box { -fx-padding: 15 0 0 0; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml index bb0a92e7..8008ff42 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml @@ -38,18 +38,33 @@ - - - + + + + + + + + + + diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css index c63bbd18..805f31b3 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css @@ -119,7 +119,7 @@ -fx-fill: white; } -.duplicate-warning { +.duplicate-warning, .fail-warning { -fx-text-fill: rgb(202, 18, 67); } diff --git a/src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml b/src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml index 84a7dd81..a8fa0d14 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml @@ -39,9 +39,45 @@ -