diff --git a/drongo b/drongo index 67836b2b..b4f4cc87 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 67836b2b557839317316a3e1c8d18b98a51d0e29 +Subproject commit b4f4cc8726de3e7b5f875816affe1e0f78f2fa25 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index c95dc15f..e724fb13 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -899,7 +899,7 @@ public class AppController implements Initializable { if(wallet.isWhirlpoolMasterWallet()) { String walletId = storage.getWalletId(wallet); Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId); - whirlpool.setScode(wallet.getOrCreateMixConfig().getScode()); + whirlpool.setScode(wallet.getMasterMixConfig().getScode()); whirlpool.setHDWallet(storage.getWalletId(wallet), copy); } @@ -1186,6 +1186,14 @@ public class AppController implements Initializable { tab.setGraphic(tabLabel); tab.setContextMenu(getTabContextMenu(tab)); tab.setClosable(true); + tab.setOnCloseRequest(event -> { + if(AppServices.get().getWhirlpoolForMixToWallet(((WalletTabData)tab.getUserData()).getWalletForm().getWalletId()) != null) { + Optional optType = AppServices.showWarningDialog("Close mix to wallet?", "This wallet has been configured as the final destination for mixes, and needs to be open for this to occur.\n\nAre you sure you want to close?", ButtonType.NO, ButtonType.YES); + if(optType.isPresent() && optType.get() == ButtonType.NO) { + event.consume(); + } + } + }); TabPane subTabs = new TabPane(); subTabs.setSide(Side.RIGHT); diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 9f217082..f2494433 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -11,6 +11,7 @@ import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.wallet.KeystoreSource; +import com.sparrowwallet.drongo.wallet.MixConfig; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.control.TextUtils; import com.sparrowwallet.sparrow.control.TrayManager; @@ -479,13 +480,25 @@ public class AppServices { private void startAllWhirlpool() { for(Map.Entry entry : whirlpoolMap.entrySet().stream().filter(entry -> entry.getValue().hasWallet() && !entry.getValue().isStarted()).collect(Collectors.toList())) { Wallet wallet = getWallet(entry.getKey()); - if(wallet.getMixConfig() != null && wallet.getMixConfig().getMixOnStartup() != Boolean.FALSE) { - Whirlpool.StartupService startupService = new Whirlpool.StartupService(entry.getValue()); - startupService.setOnFailed(workerStateEvent -> { - log.error("Failed to start whirlpool", workerStateEvent.getSource().getException()); - }); - startupService.start(); + Whirlpool whirlpool = entry.getValue(); + startWhirlpool(wallet, whirlpool); + } + } + + private void startWhirlpool(Wallet wallet, Whirlpool whirlpool) { + if(wallet.getMasterMixConfig().getMixOnStartup() != Boolean.FALSE) { + try { + String mixToWalletId = getWhirlpoolMixToWalletId(wallet.getMasterMixConfig()); + whirlpool.setMixToWallet(mixToWalletId, wallet.getMasterMixConfig().getMinMixes()); + } catch(NoSuchElementException e) { + showWarningDialog("Mix to wallet not open", wallet.getName() + " is configured to mix to " + wallet.getMasterMixConfig().getMixToWalletName() + ", but this wallet is not open. Mix to wallets are required to be open to avoid address reuse."); } + + Whirlpool.StartupService startupService = new Whirlpool.StartupService(whirlpool); + startupService.setOnFailed(workerStateEvent -> { + log.error("Failed to start whirlpool", workerStateEvent.getSource().getException()); + }); + startupService.start(); } } @@ -499,6 +512,21 @@ public class AppServices { } } + public String getWhirlpoolMixToWalletId(MixConfig mixConfig) { + if(mixConfig == null || mixConfig.getMixToWalletFile() == null || mixConfig.getMixToWalletName() == null) { + return null; + } + + return getOpenWallets().entrySet().stream() + .filter(entry -> entry.getValue().getWalletFile().equals(mixConfig.getMixToWalletFile()) && entry.getKey().getName().equals(mixConfig.getMixToWalletName())) + .map(entry -> entry.getValue().getWalletId(entry.getKey())) + .findFirst().orElseThrow(); + } + + public Whirlpool getWhirlpoolForMixToWallet(String walletId) { + return whirlpoolMap.values().stream().filter(whirlpool -> walletId.equals(whirlpool.getMixToWalletId())).findFirst().orElse(null); + } + public void minimizeStage(Stage stage) { if(trayManager == null) { trayManager = new TrayManager(); @@ -977,12 +1005,20 @@ public class AppServices { public void walletOpened(WalletOpenedEvent event) { String walletId = event.getStorage().getWalletId(event.getWallet()); Whirlpool whirlpool = whirlpoolMap.get(walletId); - if(whirlpool != null && !whirlpool.isStarted() && isConnected() && event.getWallet().getMixConfig() != null && event.getWallet().getMixConfig().getMixOnStartup() != Boolean.FALSE) { - Whirlpool.StartupService startupService = new Whirlpool.StartupService(whirlpool); - startupService.setOnFailed(workerStateEvent -> { - log.error("Failed to start whirlpool", workerStateEvent.getSource().getException()); - }); - startupService.start(); + if(whirlpool != null && !whirlpool.isStarted() && isConnected()) { + startWhirlpool(event.getWallet(), whirlpool); + } + + Whirlpool mixFromWhirlpool = whirlpoolMap.entrySet().stream().filter(entry -> event.getStorage().getWalletFile().equals(getWallet(entry.getKey()).getMasterMixConfig().getMixToWalletFile())).map(Map.Entry::getValue).findFirst().orElse(null); + if(mixFromWhirlpool != null) { + mixFromWhirlpool.setMixToWallet(walletId, getWallet(mixFromWhirlpool.getWalletId()).getMasterMixConfig().getMinMixes()); + if(mixFromWhirlpool.isStarted()) { + Whirlpool.RestartService restartService = new Whirlpool.RestartService(mixFromWhirlpool); + restartService.setOnFailed(workerStateEvent -> { + log.error("Failed to restart whirlpool", workerStateEvent.getSource().getException()); + }); + restartService.start(); + } } } @@ -1007,6 +1043,18 @@ public class AppServices { WhirlpoolEventService.getInstance().unregister(whirlpool); } } + + Whirlpool mixToWhirlpool = getWhirlpoolForMixToWallet(walletId); + if(mixToWhirlpool != null && event.getClosedWalletTabData().stream().noneMatch(walletTabData1 -> walletTabData1.getWalletForm().getWalletId().equals(mixToWhirlpool.getWalletId()))) { + mixToWhirlpool.setMixToWallet(null, null); + if(mixToWhirlpool.isStarted()) { + Whirlpool.RestartService restartService = new Whirlpool.RestartService(mixToWhirlpool); + restartService.setOnFailed(workerStateEvent -> { + log.error("Failed to restart whirlpool", workerStateEvent.getSource().getException()); + }); + restartService.start(); + } + } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/WalletMixConfigChangedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/WalletMixConfigChangedEvent.java index c16c6663..7b7ea89b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/WalletMixConfigChangedEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/WalletMixConfigChangedEvent.java @@ -4,6 +4,6 @@ import com.sparrowwallet.drongo.wallet.Wallet; public class WalletMixConfigChangedEvent extends WalletChangedEvent { public WalletMixConfigChangedEvent(Wallet wallet) { - super(wallet); + super(wallet.isMasterWallet() ? wallet : wallet.getMasterWallet()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/MixToController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/MixToController.java new file mode 100644 index 00000000..63d483ee --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/MixToController.java @@ -0,0 +1,85 @@ +package com.sparrowwallet.sparrow.wallet; + +import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.drongo.wallet.MixConfig; +import com.sparrowwallet.drongo.wallet.StandardAccount; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WalletMixConfigChangedEvent; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import javafx.collections.FXCollections; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; + +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.ResourceBundle; +import java.util.stream.Collectors; + +public class MixToController implements Initializable { + private static final Wallet NONE_WALLET = new Wallet("None"); + + @FXML + private ComboBox mixToWallets; + + @FXML + private Spinner minMixes; + + @Override + public void initialize(URL location, ResourceBundle resources) { + + } + + public void initializeView(Wallet wallet) { + MixConfig mixConfig = wallet.getMasterMixConfig(); + + List allWallets = new ArrayList<>(); + allWallets.add(NONE_WALLET); + + List destinationWallets = AppServices.get().getOpenWallets().keySet().stream().filter(openWallet -> openWallet.isValid() + && openWallet != wallet && openWallet != wallet.getMasterWallet() + && openWallet.getPolicyType().equals(PolicyType.SINGLE) + && !StandardAccount.WHIRLPOOL_ACCOUNTS.contains(openWallet.getStandardAccountType())).collect(Collectors.toList()); + allWallets.addAll(destinationWallets); + + mixToWallets.setItems(FXCollections.observableList(allWallets)); + + String mixToWalletId = null; + try { + mixToWalletId = AppServices.get().getWhirlpoolMixToWalletId(mixConfig); + } catch(NoSuchElementException e) { + //ignore, mix to wallet is not open + } + + if(mixToWalletId != null) { + mixToWallets.setValue(AppServices.get().getWallet(mixToWalletId)); + } else { + mixToWallets.setValue(NONE_WALLET); + } + + mixToWallets.valueProperty().addListener((observable, oldValue, newValue) -> { + if(newValue == NONE_WALLET) { + mixConfig.setMixToWalletName(null); + mixConfig.setMixToWalletFile(null); + } else { + mixConfig.setMixToWalletName(newValue.getName()); + mixConfig.setMixToWalletFile(AppServices.get().getOpenWallets().get(newValue).getWalletFile()); + } + + EventManager.get().post(new WalletMixConfigChangedEvent(wallet)); + }); + + int initialMinMixes = mixConfig.getMinMixes() == null ? Whirlpool.DEFAULT_MIXTO_MIN_MIXES : mixConfig.getMinMixes(); + minMixes.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 10000, initialMinMixes)); + minMixes.valueProperty().addListener((observable, oldValue, newValue) -> { + mixConfig.setMinMixes(newValue); + EventManager.get().post(new WalletMixConfigChangedEvent(wallet)); + }); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/MixToDialog.java b/src/main/java/com/sparrowwallet/sparrow/wallet/MixToDialog.java new file mode 100644 index 00000000..7617a56f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/MixToDialog.java @@ -0,0 +1,68 @@ +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.WalletMixConfigChangedEvent; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.*; +import org.controlsfx.tools.Borders; + +import java.io.IOException; +import java.util.NoSuchElementException; + +public class MixToDialog extends Dialog { + private final Wallet wallet; + private final Button applyButton; + + public MixToDialog(Wallet wallet) { + final DialogPane dialogPane = getDialogPane(); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + this.wallet = wallet; + + try { + FXMLLoader mixToLoader = new FXMLLoader(AppServices.class.getResource("wallet/mixto.fxml")); + dialogPane.setContent(Borders.wrap(mixToLoader.load()).emptyBorder().buildAll()); + MixToController mixToController = mixToLoader.getController(); + mixToController.initializeView(wallet); + + Whirlpool whirlpool = AppServices.get().getWhirlpool(wallet); + final ButtonType closeButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE); + final ButtonType applyButtonType = new javafx.scene.control.ButtonType(whirlpool.isStarted() ? "Restart Whirlpool" : "Apply", ButtonBar.ButtonData.APPLY); + dialogPane.getButtonTypes().addAll(closeButtonType, applyButtonType); + + applyButton = (Button)dialogPane.lookupButton(applyButtonType); + applyButton.setDisable(true); + applyButton.setDefaultButton(true); + + try { + AppServices.get().getWhirlpoolMixToWalletId(wallet.getMasterMixConfig()); + } catch(NoSuchElementException e) { + applyButton.setDisable(false); + } + + dialogPane.setPrefWidth(400); + dialogPane.setPrefHeight(300); + AppServices.moveToActiveWindowScreen(this); + + setResultConverter(dialogButton -> dialogButton == applyButtonType); + + setOnCloseRequest(event -> { + EventManager.get().unregister(this); + }); + EventManager.get().register(this); + } + catch(IOException e) { + throw new RuntimeException(e); + } + } + + @Subscribe + public void walletMixConfigChanged(WalletMixConfigChangedEvent event) { + if(event.getWallet() == (wallet.isMasterWallet() ? wallet : wallet.getMasterWallet())) { + applyButton.setDisable(false); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java index a81e5b25..b61497ad 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java @@ -16,6 +16,7 @@ import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.whirlpool.Whirlpool; @@ -32,6 +33,7 @@ import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; import javafx.stage.FileChooser; import javafx.stage.Stage; +import org.controlsfx.glyphfont.Glyph; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,6 +60,9 @@ public class UtxosController extends WalletFormController implements Initializab @FXML private Button stopMix; + @FXML + private Button mixTo; + @FXML private Button sendSelected; @@ -73,6 +78,11 @@ public class UtxosController extends WalletFormController implements Initializab stopMix.setDisable(!newValue); }; + private final ChangeListener mixingStartingListener = (observable, oldValue, newValue) -> { + startMix.setDisable(newValue); + mixTo.setDisable(newValue); + }; + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -93,10 +103,15 @@ public class UtxosController extends WalletFormController implements Initializab stopMix.setDisable(!newValue); startMix.setDisable(newValue); }); + mixTo.managedProperty().bind(mixTo.visibleProperty()); + mixTo.setVisible(getWalletForm().getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX); + if(mixButtonsBox.isVisible()) { Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet()); if(whirlpool != null) { stopMix.visibleProperty().bind(whirlpool.mixingProperty()); + whirlpool.startingProperty().addListener(new WeakChangeListener<>(mixingStartingListener)); + updateMixToButton(); } } @@ -146,6 +161,25 @@ public class UtxosController extends WalletFormController implements Initializab } } + private void updateMixToButton() { + MixConfig mixConfig = getWalletForm().getWallet().getMasterMixConfig(); + if(mixConfig != null && mixConfig.getMixToWalletName() != null) { + mixTo.setText("Mix to " + mixConfig.getMixToWalletName()); + try { + AppServices.get().getWhirlpoolMixToWalletId(mixConfig); + mixTo.setGraphic(getExternalGlyph()); + mixTo.setTooltip(new Tooltip("Mixing to " + mixConfig.getMixToWalletName() + " after at least " + (mixConfig.getMinMixes() == null ? Whirlpool.DEFAULT_MIXTO_MIN_MIXES : mixConfig.getMinMixes()) + " mixes")); + } catch(NoSuchElementException e) { + mixTo.setGraphic(getErrorGlyph()); + mixTo.setTooltip(new Tooltip(mixConfig.getMixToWalletName() + " is not open - open this wallet to mix to it!")); + } + } else { + mixTo.setText("Mix to..."); + mixTo.setGraphic(null); + mixTo.setTooltip(null); + } + } + private List getSelectedEntries() { return utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> (UtxoEntry)tp.getTreeItem().getValue()) .filter(utxoEntry -> utxoEntry.isSpendable() && !utxoEntry.isMixing()) @@ -215,7 +249,7 @@ public class UtxosController extends WalletFormController implements Initializab private void prepareWhirlpoolWallet(Wallet decryptedWallet) { Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWalletId()); - whirlpool.setScode(decryptedWallet.getOrCreateMixConfig().getScode()); + whirlpool.setScode(decryptedWallet.getMasterMixConfig().getScode()); whirlpool.setHDWallet(getWalletForm().getWalletId(), decryptedWallet); for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) { @@ -287,9 +321,8 @@ public class UtxosController extends WalletFormController implements Initializab startupService.start(); } - Wallet masterWallet = getWalletForm().getWallet().isMasterWallet() ? getWalletForm().getWallet() : getWalletForm().getWallet().getMasterWallet(); - masterWallet.getOrCreateMixConfig().setMixOnStartup(Boolean.TRUE); - EventManager.get().post(new WalletMixConfigChangedEvent(masterWallet)); + getWalletForm().getWallet().getMasterMixConfig().setMixOnStartup(Boolean.TRUE); + EventManager.get().post(new WalletMixConfigChangedEvent(getWalletForm().getWallet())); } public void stopMixing(ActionEvent event) { @@ -309,9 +342,36 @@ public class UtxosController extends WalletFormController implements Initializab whirlpool.shutdown(); } - Wallet masterWallet = getWalletForm().getWallet().isMasterWallet() ? getWalletForm().getWallet() : getWalletForm().getWallet().getMasterWallet(); - masterWallet.getOrCreateMixConfig().setMixOnStartup(Boolean.FALSE); - EventManager.get().post(new WalletMixConfigChangedEvent(masterWallet)); + getWalletForm().getWallet().getMasterMixConfig().setMixOnStartup(Boolean.FALSE); + EventManager.get().post(new WalletMixConfigChangedEvent(getWalletForm().getWallet())); + } + + public void showMixToDialog(ActionEvent event) { + MixToDialog mixToDialog = new MixToDialog(getWalletForm().getWallet()); + Optional optApply = mixToDialog.showAndWait(); + if(optApply.isPresent() && optApply.get()) { + Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet()); + MixConfig mixConfig = getWalletForm().getWallet().getMasterMixConfig(); + + try { + String mixToWalletId = AppServices.get().getWhirlpoolMixToWalletId(mixConfig); + whirlpool.setMixToWallet(mixToWalletId, mixConfig.getMinMixes()); + } catch(NoSuchElementException e) { + mixConfig.setMixToWalletName(null); + mixConfig.setMixToWalletFile(null); + EventManager.get().post(new WalletMixConfigChangedEvent(getWalletForm().getWallet())); + whirlpool.setMixToWallet(null, null); + } + + updateMixToButton(); + if(whirlpool.isStarted()) { + Whirlpool.RestartService restartService = new Whirlpool.RestartService(whirlpool); + restartService.setOnFailed(workerStateEvent -> { + log.error("Failed to restart whirlpool", workerStateEvent.getSource().getException()); + }); + restartService.start(); + } + } } public void exportUtxos(ActionEvent event) { @@ -350,6 +410,19 @@ public class UtxosController extends WalletFormController implements Initializab String.format(Locale.ENGLISH, "%d", value); } + private static Glyph getExternalGlyph() { + Glyph externalGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXTERNAL_LINK_ALT); + externalGlyph.setFontSize(12); + return externalGlyph; + } + + private static Glyph getErrorGlyph() { + Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE); + glyph.getStyleClass().add("failure"); + glyph.setFontSize(12); + return glyph; + } + @Subscribe public void walletNodesChanged(WalletNodesChangedEvent event) { if(event.getWallet().equals(walletForm.getWallet())) { @@ -434,6 +507,11 @@ public class UtxosController extends WalletFormController implements Initializab utxosChart.setVisible(event.isVisible() && !getWalletForm().getWallet().isWhirlpoolMixWallet()); } + @Subscribe + public void openWallets(OpenWalletsEvent event) { + Platform.runLater(this::updateMixToButton); + } + @Subscribe public void whirlpoolMix(WhirlpoolMixEvent event) { if(event.getWallet().equals(walletForm.getWallet())) { diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java index 30d54cf3..3923c542 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java @@ -24,6 +24,7 @@ import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; @@ -57,6 +58,9 @@ import java.util.stream.Collectors; public class Whirlpool { private static final Logger log = LoggerFactory.getLogger(Whirlpool.class); + public static final int DEFAULT_MIXTO_MIN_MIXES = 5; + public static final int DEFAULT_MIXTO_RANDOM_FACTOR = 4; + private final HostAndPort torProxy; private final WhirlpoolServer whirlpoolServer; private final JavaHttpClientService httpClientService; @@ -66,7 +70,9 @@ public class Whirlpool { private final WhirlpoolWalletConfig config; private HD_Wallet hdWallet; private String walletId; + private String mixToWalletId; + private final BooleanProperty startingProperty = new SimpleBooleanProperty(false); private final BooleanProperty mixingProperty = new SimpleBooleanProperty(false); public Whirlpool(Network network, HostAndPort torProxy) { @@ -342,12 +348,39 @@ public class Whirlpool { config.setScode(scode); } - public void setExternalDestination(ExternalDestination externalDestination) { - if(whirlpoolWalletService.whirlpoolWallet() != null) { - throw new IllegalStateException("Cannot set external destination while WhirlpoolWallet is running"); + public String getWalletId() { + return walletId; + } + + public String getMixToWalletId() { + return mixToWalletId; + } + + public void setMixToWallet(String mixToWalletId, Integer minMixes) { + if(mixToWalletId == null) { + config.setExternalDestination(null); + } else { + Wallet mixToWallet = getWallet(mixToWalletId); + if(mixToWallet == null) { + throw new IllegalStateException("Cannot find mix to wallet with id " + mixToWalletId); + } + + if(mixToWallet.getPolicyType() != PolicyType.SINGLE) { + throw new IllegalStateException("Only single signature mix to wallets are currently supported"); + } + + List headers = ExtendedKey.Header.getHeaders(Network.get()); + ExtendedKey.Header header = headers.stream().filter(head -> head.getDefaultScriptType().equals(mixToWallet.getScriptType()) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); + ExtendedKey extPubKey = mixToWallet.getKeystores().get(0).getExtendedPublicKey(); + String xpub = extPubKey.toString(header); + Integer highestUsedIndex = mixToWallet.getNode(KeyPurpose.RECEIVE).getHighestUsedIndex(); + int mixes = minMixes == null ? DEFAULT_MIXTO_MIN_MIXES : minMixes; + + ExternalDestination externalDestination = new ExternalDestination(xpub, 0, highestUsedIndex == null ? 0 : highestUsedIndex + 1, mixes, DEFAULT_MIXTO_RANDOM_FACTOR); + config.setExternalDestination(externalDestination); } - config.setExternalDestination(externalDestination); + this.mixToWalletId = mixToWalletId; } public boolean isMixing() { @@ -358,6 +391,14 @@ public class Whirlpool { return mixingProperty; } + public boolean isStarting() { + return startingProperty.get(); + } + + public BooleanProperty startingProperty() { + return startingProperty; + } + @Subscribe public void onMixSuccess(MixSuccessEvent e) { WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo()); @@ -398,6 +439,7 @@ public class Whirlpool { @Subscribe public void onWalletStart(WalletStartEvent e) { if(e.getWhirlpoolWallet() == whirlpoolWalletService.whirlpoolWallet()) { + log.info("Mixing to " + e.getWhirlpoolWallet().getConfig().getExternalDestination()); mixingProperty.set(true); } } @@ -492,9 +534,11 @@ public class Whirlpool { updateProgress(-1, 1); updateMessage("Starting Whirlpool..."); + whirlpool.startingProperty.set(true); WhirlpoolWallet whirlpoolWallet = whirlpool.getWhirlpoolWallet(); if(AppServices.onlineProperty().get()) { whirlpoolWallet.start(); + whirlpool.startingProperty.set(false); } return whirlpoolWallet; @@ -524,6 +568,35 @@ public class Whirlpool { } } + public static class RestartService extends Service { + private final Whirlpool whirlpool; + + public RestartService(Whirlpool whirlpool) { + this.whirlpool = whirlpool; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected Boolean call() throws Exception { + updateProgress(-1, 1); + updateMessage("Disconnecting from Whirlpool..."); + whirlpool.shutdown(); + + updateMessage("Starting Whirlpool..."); + whirlpool.startingProperty.set(true); + WhirlpoolWallet whirlpoolWallet = whirlpool.getWhirlpoolWallet(); + if(AppServices.onlineProperty().get()) { + whirlpoolWallet.start(); + whirlpool.startingProperty.set(false); + } + + return true; + } + }; + } + } + public static class WalletUtxo { public final Wallet wallet; public final BlockTransactionHashIndex utxo; diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java index 3066bd48..b5d5200f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java @@ -83,7 +83,7 @@ public class WhirlpoolController { this.walletId = walletId; this.wallet = wallet; this.utxoEntries = utxoEntries; - this.mixConfig = wallet.isMasterWallet() ? wallet.getOrCreateMixConfig() : wallet.getMasterWallet().getOrCreateMixConfig(); + this.mixConfig = wallet.getMasterMixConfig(); step1.managedProperty().bind(step1.visibleProperty()); step2.managedProperty().bind(step2.visibleProperty()); @@ -97,7 +97,7 @@ public class WhirlpoolController { scode.setText(mixConfig.getScode() == null ? "" : mixConfig.getScode()); scode.textProperty().addListener((observable, oldValue, newValue) -> { mixConfig.setScode(newValue); - EventManager.get().post(new WalletMixConfigChangedEvent(wallet.isMasterWallet() ? wallet : wallet.getMasterWallet())); + EventManager.get().post(new WalletMixConfigChangedEvent(wallet)); }); if(mixConfig.getScode() != null) { @@ -232,7 +232,7 @@ public class WhirlpoolController { private void fetchTx0Preview(Pool pool) { if(mixConfig.getScode() == null) { mixConfig.setScode(""); - EventManager.get().post(new WalletMixConfigChangedEvent(wallet.isMasterWallet() ? wallet : wallet.getMasterWallet())); + EventManager.get().post(new WalletMixConfigChangedEvent(wallet)); } Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId); diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java index 0270f955..c3d5522a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java @@ -3,7 +3,6 @@ package com.sparrowwallet.sparrow.whirlpool; import com.samourai.whirlpool.client.tx0.Tx0Preview; 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; @@ -54,8 +53,7 @@ public class WhirlpoolDialog extends Dialog { backButton.managedProperty().bind(backButton.visibleProperty()); previewButton.managedProperty().bind(previewButton.visibleProperty()); - Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); - if(masterWallet.getOrCreateMixConfig().getScode() == null) { + if(wallet.getMasterMixConfig().getScode() == null) { backButton.setDisable(true); } previewButton.visibleProperty().bind(nextButton.visibleProperty().not()); diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowDataSource.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowDataSource.java index 6d3233d5..2c3852d0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowDataSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowDataSource.java @@ -178,7 +178,7 @@ public class SparrowDataSource extends WalletResponseDataSource { return SparrowMinerFeeSupplier.getInstance(); } - private Wallet getWallet(String zpub) { + static Wallet getWallet(String zpub) { return AppServices.get().getOpenWallets().keySet().stream() .filter(Wallet::isValid) .filter(wallet -> { diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowWalletStateSupplier.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowWalletStateSupplier.java index 7e511488..6ebe6cfa 100644 --- a/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowWalletStateSupplier.java +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowWalletStateSupplier.java @@ -7,6 +7,7 @@ import com.samourai.whirlpool.client.wallet.beans.ExternalDestination; import com.samourai.whirlpool.client.wallet.beans.WhirlpoolAccount; import com.samourai.whirlpool.client.wallet.data.walletState.WalletStateSupplier; import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.crypto.ChildNumber; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletNode; import com.sparrowwallet.sparrow.whirlpool.Whirlpool; @@ -17,12 +18,13 @@ import java.util.Map; public class SparrowWalletStateSupplier implements WalletStateSupplier { private final String walletId; private final Map indexHandlerWallets; - // private int externalIndexDefault; + private final ExternalDestination externalDestination; + private IIndexHandler externalIndexHandler; public SparrowWalletStateSupplier(String walletId, ExternalDestination externalDestination) throws Exception { this.walletId = walletId; this.indexHandlerWallets = new LinkedHashMap<>(); - // this.externalIndexDefault = externalDestination != null ? externalDestination.getStartIndex() : 0; + this.externalDestination = externalDestination; } @Override @@ -39,7 +41,22 @@ public class SparrowWalletStateSupplier implements WalletStateSupplier { @Override public IIndexHandler getIndexHandlerExternal() { - throw new UnsupportedOperationException(); + if(externalDestination == null) { + throw new IllegalStateException("External destination has not been set"); + } + + if(externalIndexHandler == null) { + Wallet externalWallet = SparrowDataSource.getWallet(externalDestination.getXpub()); + if(externalWallet == null) { + throw new IllegalStateException("Cannot find wallet for external destination xpub " + externalDestination.getXpub()); + } + + KeyPurpose keyPurpose = KeyPurpose.fromChildNumber(new ChildNumber(externalDestination.getChain())); + WalletNode externalNode = externalWallet.getNode(keyPurpose); + externalIndexHandler = new SparrowIndexHandler(externalNode, externalDestination.getStartIndex()); + } + + return externalIndexHandler; } @Override diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/mixto.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/mixto.fxml new file mode 100644 index 00000000..8a94410c --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/mixto.fxml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
+ + + + + + + + +
+
+
+
+
+ diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml index 8008ff42..e88abfd8 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml @@ -50,6 +50,7 @@ +