diff --git a/drongo b/drongo index 81c20219..7ac4bce1 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 81c202198e8b057271414d15259df556a90bc6f1 +Subproject commit 7ac4bce14f04163c57b94e34945b5e4a1bf79eb6 diff --git a/src/main/java/com/sparrowwallet/sparrow/control/HelpLabel.java b/src/main/java/com/sparrowwallet/sparrow/control/HelpLabel.java index 4dac1cfe..4c176ee0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/HelpLabel.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/HelpLabel.java @@ -2,13 +2,13 @@ package com.sparrowwallet.sparrow.control; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; -import javafx.event.EventHandler; -import javafx.geometry.Bounds; +import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.Tooltip; -import javafx.scene.input.MouseEvent; import javafx.util.Duration; import org.controlsfx.glyphfont.Glyph; @@ -19,6 +19,7 @@ public class HelpLabel extends Label { super("", getHelpGlyph()); tooltip = new Tooltip(); tooltip.textProperty().bind(helpTextProperty()); + tooltip.graphicProperty().bind(helpGraphicProperty()); tooltip.setShowDuration(Duration.seconds(15)); getStyleClass().add("help-label"); @@ -49,4 +50,18 @@ public class HelpLabel extends Label { public final String getHelpText() { return helpText == null ? "" : helpText.getValue(); } + + public ObjectProperty helpGraphicProperty() { + if(helpGraphicProperty == null) { + helpGraphicProperty = new SimpleObjectProperty(this, "helpGraphic", null); + } + + return helpGraphicProperty; + } + + private ObjectProperty helpGraphicProperty; + + public final void setHelpGraphic(Node graphic) { + helpGraphicProperty().setValue(graphic); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index 08dc6da5..ba76f650 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -162,28 +162,28 @@ public class TransactionDiagram extends GridPane { topYaxis.setStartX(width * 0.5); topYaxis.setStartY(getDiagramHeight() * 0.5 - 20.0); topYaxis.setEndX(width * 0.5); - topYaxis.setEndY(0); + topYaxis.setEndY(10); topYaxis.getStyleClass().add("inputs-type"); Line topBracket = new Line(); topBracket.setStartX(width * 0.5); - topBracket.setStartY(0); + topBracket.setStartY(10); topBracket.setEndX(width); - topBracket.setEndY(0); + topBracket.setEndY(10); topBracket.getStyleClass().add("inputs-type"); Line bottomYaxis = new Line(); bottomYaxis.setStartX(width * 0.5); - bottomYaxis.setStartY(getDiagramHeight()); + bottomYaxis.setStartY(getDiagramHeight() - 10); bottomYaxis.setEndX(width * 0.5); bottomYaxis.setEndY(getDiagramHeight() * 0.5 + 20.0); bottomYaxis.getStyleClass().add("inputs-type"); Line bottomBracket = new Line(); bottomBracket.setStartX(width * 0.5); - bottomBracket.setStartY(getDiagramHeight()); + bottomBracket.setStartY(getDiagramHeight() - 10); bottomBracket.setEndX(width); - bottomBracket.setEndY(getDiagramHeight()); + bottomBracket.setEndY(getDiagramHeight() - 10); bottomBracket.getStyleClass().add("inputs-type"); group.getChildren().addAll(widthLine, topYaxis, topBracket, bottomYaxis, bottomBracket); @@ -344,7 +344,7 @@ public class TransactionDiagram extends GridPane { group.getChildren().add(yaxisLine); double width = 140.0; - int numOutputs = displayedPayments.size() + (walletTx.getChangeNode() == null ? 1 : 2); + int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1; for(int i = 1; i <= numOutputs; i++) { CubicCurve curve = new CubicCurve(); curve.getStyleClass().add("output-line"); @@ -391,15 +391,16 @@ public class TransactionDiagram extends GridPane { outputsBox.getChildren().add(createSpacer()); } - if(walletTx.getChangeNode() != null) { + for(Map.Entry changeEntry : walletTx.getChangeMap().entrySet()) { + WalletNode changeNode = changeEntry.getKey(); WalletNode defaultChangeNode = walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE); - boolean overGapLimit = (walletTx.getChangeNode().getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit(); + boolean overGapLimit = (changeNode.getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit(); HBox actionBox = new HBox(); - String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "..."; + String changeDesc = walletTx.getChangeAddress(changeNode).toString().substring(0, 8) + "..."; Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph()); changeLabel.getStyleClass().addAll("output-label", "change-label"); - Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : "")); + Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(changeEntry.getValue()) + " sats to " + changeNode.getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress(changeNode).toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : "")); changeTooltip.getStyleClass().add("change-label"); changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); changeLabel.setTooltip(changeTooltip); @@ -469,7 +470,9 @@ public class TransactionDiagram extends GridPane { } public Glyph getOutputGlyph(Payment payment) { - if(walletTx.isConsolidationSend(payment)) { + if(payment.getType().equals(Payment.Type.FAKE_MIX)) { + return getFakeMixGlyph(); + } else if(walletTx.isConsolidationSend(payment)) { return getConsolidationGlyph(); } else if(walletTx.isPremixSend(payment)) { return getPremixGlyph(); @@ -526,6 +529,13 @@ public class TransactionDiagram extends GridPane { return whirlpoolFeeGlyph; } + public static Glyph getFakeMixGlyph() { + Glyph fakeMixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.THEATER_MASKS); + fakeMixGlyph.getStyleClass().add("fakemix-icon"); + fakeMixGlyph.setFontSize(12); + return fakeMixGlyph; + } + public static Glyph getTxoGlyph() { return getChangeGlyph(); } diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index ef824231..296e69b2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -33,6 +33,7 @@ public class FontAwesome5 extends GlyphFont { EXTERNAL_LINK_ALT('\uf35d'), ELLIPSIS_H('\uf141'), EYE('\uf06e'), + FEATHER_ALT('\uf56b'), FILE_CSV('\uf6dd'), HAND_HOLDING('\uf4bd'), HAND_HOLDING_MEDICAL('\ue05c'), @@ -42,6 +43,7 @@ public class FontAwesome5 extends GlyphFont { LAPTOP('\uf109'), LOCK('\uf023'), LOCK_OPEN('\uf3c1'), + MINUS_CIRCLE('\uf056'), PEN_FANCY('\uf5ac'), PLUS('\uf067'), PLAY_CIRCLE('\uf144'), @@ -58,13 +60,15 @@ public class FontAwesome5 extends GlyphFont { SQUARE('\uf0c8'), SNOWFLAKE('\uf2dc'), SUN('\uf185'), + THEATER_MASKS('\uf630'), TIMES_CIRCLE('\uf057'), TOGGLE_OFF('\uf204'), TOGGLE_ON('\uf205'), TOOLS('\uf7d9'), UNDO('\uf0e2'), USER_FRIENDS('\uf500'), - WALLET('\uf555'); + WALLET('\uf555'), + WEIGHT('\uf496'); private final char ch; diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index 16c7a668..61da15f1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.Mode; import com.sparrowwallet.sparrow.Theme; import com.sparrowwallet.sparrow.net.*; import com.sparrowwallet.sparrow.wallet.FeeRatesSelection; +import com.sparrowwallet.sparrow.wallet.OptimizationStrategy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +27,7 @@ public class Config { private BitcoinUnit bitcoinUnit; private FeeRatesSource feeRatesSource; private FeeRatesSelection feeRatesSelection; + private OptimizationStrategy sendOptimizationStrategy; private Currency fiatCurrency; private ExchangeSource exchangeSource; private boolean loadRecentWallets = true; @@ -139,6 +141,15 @@ public class Config { flush(); } + public OptimizationStrategy getSendOptimizationStrategy() { + return sendOptimizationStrategy; + } + + public void setSendOptimizationStrategy(OptimizationStrategy sendOptimizationStrategy) { + this.sendOptimizationStrategy = sendOptimizationStrategy; + flush(); + } + public Currency getFiatCurrency() { return fiatCurrency; } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/OptimizationStrategy.java b/src/main/java/com/sparrowwallet/sparrow/wallet/OptimizationStrategy.java new file mode 100644 index 00000000..4b19e600 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/OptimizationStrategy.java @@ -0,0 +1,20 @@ +package com.sparrowwallet.sparrow.wallet; + +public enum OptimizationStrategy { + EFFICIENCY("Efficiency"), PRIVACY("Privacy"); + + private final String name; + + private OptimizationStrategy(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index c0070c13..ca94aeb3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.wallet; import com.google.common.eventbus.Subscribe; import com.samourai.whirlpool.client.whirlpool.beans.Pool; import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.SecureString; import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.protocol.Sha256Hash; @@ -36,6 +37,7 @@ import javafx.fxml.Initializable; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; import javafx.util.Duration; import javafx.util.StringConverter; import org.controlsfx.glyphfont.Glyph; @@ -116,6 +118,18 @@ public class SendController extends WalletFormController implements Initializabl @FXML private TransactionDiagram transactionDiagram; + @FXML + private ToggleGroup optimizationToggleGroup; + + @FXML + private ToggleButton efficiencyToggle; + + @FXML + private ToggleButton privacyToggle; + + @FXML + private HelpLabel privacyAnalysis; + @FXML private Button clearButton; @@ -145,7 +159,7 @@ public class SendController extends WalletFormController implements Initializabl private final BooleanProperty includeSpentMempoolOutputsProperty = new SimpleBooleanProperty(false); - private final List excludedChangeNodes = new ArrayList<>(); + private final Set excludedChangeNodes = new HashSet<>(); private final ChangeListener feeListener = new ChangeListener<>() { @Override @@ -208,6 +222,8 @@ public class SendController extends WalletFormController implements Initializabl private WalletTransactionService walletTransactionService; + private boolean overrideOptimizationStrategy; + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -357,7 +373,7 @@ public class SendController extends WalletFormController implements Initializabl walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> { if(walletTransaction != null) { - setPayments(walletTransaction.getPayments()); + setPayments(walletTransaction.getPayments().stream().filter(payment -> payment.getType() != Payment.Type.FAKE_MIX).collect(Collectors.toList())); double feeRate = walletTransaction.getFeeRate(); if(userFeeSet.get()) { @@ -372,6 +388,7 @@ public class SendController extends WalletFormController implements Initializabl } transactionDiagram.update(walletTransaction); + updatePrivacyAnalysis(walletTransaction); createButton.setDisable(walletTransaction == null || isInsufficientFeeRate()); }); @@ -386,6 +403,21 @@ public class SendController extends WalletFormController implements Initializabl addFeeRangeTrackHighlight(0); + efficiencyToggle.setOnAction(event -> { + if(getWalletForm().getWallet().isWhirlpoolMixWallet() && !overrideOptimizationStrategy) { + AppServices.showWarningDialog("Privacy may be lost!", "It is recommended to optimize for privacy when sending coinjoined outputs."); + overrideOptimizationStrategy = true; + } + Config.get().setSendOptimizationStrategy(OptimizationStrategy.EFFICIENCY); + updateTransaction(); + }); + privacyToggle.setOnAction(event -> { + Config.get().setSendOptimizationStrategy(OptimizationStrategy.PRIVACY); + updateTransaction(); + }); + setPreferredOptimizationStrategy(); + updatePrivacyAnalysis(null); + createButton.managedProperty().bind(createButton.visibleProperty()); premixButton.managedProperty().bind(premixButton.visibleProperty()); createButton.visibleProperty().bind(premixButton.visibleProperty().not()); @@ -508,6 +540,7 @@ public class SendController extends WalletFormController implements Initializabl try { List payments = transactionPayments != null ? transactionPayments : getPayments(); + updateOptimizationButtons(payments); if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) { Wallet wallet = getWalletForm().getWallet(); Long userFee = userFeeSet.get() ? getFeeValueSats() : null; @@ -517,7 +550,7 @@ public class SendController extends WalletFormController implements Initializabl boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs(); boolean includeSpentMempoolOutputs = includeSpentMempoolOutputsProperty.get(); - walletTransactionService = new WalletTransactionService(wallet, getUtxoSelectors(), getUtxoFilters(), payments, excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs); + walletTransactionService = new WalletTransactionService(wallet, getUtxoSelectors(payments), getUtxoFilters(), payments, excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs); walletTransactionService.setOnSucceeded(event -> { if(!walletTransactionService.isIgnoreResult()) { walletTransactionProperty.setValue(walletTransactionService.getValue()); @@ -551,7 +584,7 @@ public class SendController extends WalletFormController implements Initializabl } } - private List getUtxoSelectors() throws InvalidAddressException { + private List getUtxoSelectors(List payments) throws InvalidAddressException { if(utxoSelectorProperty.get() != null) { return List.of(utxoSelectorProperty.get()); } @@ -560,7 +593,16 @@ public class SendController extends WalletFormController implements Initializabl long noInputsFee = wallet.getNoInputsFee(getPayments(), getUserFeeRate()); long costOfChange = wallet.getCostOfChange(getUserFeeRate(), getMinimumFeeRate()); - return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee)); + List selectors = new ArrayList<>(); + OptimizationStrategy optimizationStrategy = (OptimizationStrategy)optimizationToggleGroup.getSelectedToggle().getUserData(); + if(optimizationStrategy == OptimizationStrategy.PRIVACY + && payments.size() == 1 + && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())) { + selectors.add(new StonewallUtxoSelector(noInputsFee)); + } + + selectors.addAll(List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee))); + return selectors; } private static class WalletTransactionService extends Service { @@ -568,7 +610,7 @@ public class SendController extends WalletFormController implements Initializabl private final List utxoSelectors; private final List utxoFilters; private final List payments; - private final List excludedChangeNodes; + private final Set excludedChangeNodes; private final double feeRate; private final double longTermFeeRate; private final Long fee; @@ -578,7 +620,7 @@ public class SendController extends WalletFormController implements Initializabl private final boolean includeSpentMempoolOutputs; private boolean ignoreResult; - public WalletTransactionService(Wallet wallet, List utxoSelectors, List utxoFilters, List payments, List excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) { + public WalletTransactionService(Wallet wallet, List utxoSelectors, List utxoFilters, List payments, Set excludedChangeNodes, double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, boolean includeSpentMempoolOutputs) { this.wallet = wallet; this.utxoSelectors = utxoSelectors; this.utxoFilters = utxoFilters; @@ -898,6 +940,45 @@ public class SendController extends WalletFormController implements Initializabl } } + private boolean isFakeMixPossible(List payments) { + return (utxoSelectorProperty.get() == null + && payments.size() == 1 + && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())); + } + + private void updateOptimizationButtons(List payments) { + if(isFakeMixPossible(payments)) { + setPreferredOptimizationStrategy(); + privacyToggle.setDisable(false); + } else { + optimizationToggleGroup.selectToggle(efficiencyToggle); + privacyToggle.setDisable(true); + } + } + + private OptimizationStrategy getPreferredOptimizationStrategy() { + OptimizationStrategy optimizationStrategy = Config.get().getSendOptimizationStrategy(); + if(getWalletForm().getWallet().isWhirlpoolMixWallet() && !overrideOptimizationStrategy) { + optimizationStrategy = OptimizationStrategy.PRIVACY; + } + + return optimizationStrategy; + } + + private void setPreferredOptimizationStrategy() { + optimizationToggleGroup.selectToggle(getPreferredOptimizationStrategy() == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle); + } + + private void updatePrivacyAnalysis(WalletTransaction walletTransaction) { + if(walletTransaction == null) { + privacyAnalysis.setHelpText("Determines whether to optimize the transaction for low fees or greater privacy"); + privacyAnalysis.setHelpGraphic(null); + } else { + privacyAnalysis.setHelpText(""); + privacyAnalysis.setHelpGraphic(new PrivacyAnalysisTooltip(walletTransaction)); + } + } + public void clear(ActionEvent event) { boolean firstTab = true; for(Iterator iterator = paymentTabs.getTabs().iterator(); iterator.hasNext(); ) { @@ -990,9 +1071,9 @@ public class SendController extends WalletFormController implements Initializabl List nodeHashes = inputHashes.computeIfAbsent(node, k -> new ArrayList<>()); nodeHashes.add(ElectrumServer.getScriptHash(walletForm.getWallet(), node)); } - Map> changeHash = Collections.emptyMap(); - if(walletTransactionProperty.get().getChangeNode() != null) { - changeHash = Map.of(walletTransactionProperty.get().getChangeNode(), List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), walletTransactionProperty.get().getChangeNode()))); + Map> changeHash = new LinkedHashMap<>(); + for(WalletNode changeNode : walletTransactionProperty.get().getChangeMap().keySet()) { + changeHash.put(changeNode, List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), changeNode))); } log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash); } @@ -1006,9 +1087,7 @@ public class SendController extends WalletFormController implements Initializabl private void addWalletTransactionNodes() { WalletTransaction walletTransaction = walletTransactionProperty.get(); Set nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values()); - if(walletTransaction.getChangeNode() != null) { - nodes.add(walletTransaction.getChangeNode()); - } + nodes.addAll(walletTransaction.getChangeMap().keySet()); List consolidationNodes = walletTransaction.getConsolidationSendNodes(); nodes.addAll(consolidationNodes); @@ -1262,7 +1341,7 @@ public class SendController extends WalletFormController implements Initializabl @Subscribe public void replaceChangeAddress(ReplaceChangeAddressEvent event) { if(event.getWalletTransaction() == walletTransactionProperty.get()) { - excludedChangeNodes.add(event.getWalletTransaction().getChangeNode()); + excludedChangeNodes.addAll(event.getWalletTransaction().getChangeMap().keySet()); updateTransaction(); } } @@ -1295,4 +1374,85 @@ public class SendController extends WalletFormController implements Initializabl updateTransaction(); } } + + private class PrivacyAnalysisTooltip extends VBox { + private List