diff --git a/build.gradle b/build.gradle index f9a1ff54..6d782dcd 100644 --- a/build.gradle +++ b/build.gradle @@ -91,7 +91,10 @@ dependencies { implementation('org.slf4j:jul-to-slf4j:1.7.30') { exclude group: 'org.slf4j' } - implementation('com.sparrowwallet.nightjar:nightjar:0.2.19') + implementation('com.sparrowwallet.nightjar:nightjar:0.2.21') + implementation('io.reactivex.rxjava2:rxjava:2.2.15') + implementation('io.reactivex.rxjava2:rxjavafx:2.2.2') + implementation('org.apache.commons:commons-lang3:3.7') testImplementation('junit:junit:4.12') } @@ -431,6 +434,11 @@ extraJavaModuleInfo { requires('javafx.graphics') requires('javafx.controls') } + module('rxjavafx-2.2.2.jar', 'io.reactivex.rxjava2fx', '2.2.2') { + exports('io.reactivex.rxjavafx.schedulers') + requires('io.reactivex.rxjava2') + requires('javafx.graphics') + } module('wellbehavedfx-0.3.3.jar', 'org.fxmisc.wellbehaved', '0.3.3') { requires('javafx.base') requires('javafx.graphics') @@ -449,7 +457,7 @@ extraJavaModuleInfo { module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') { exports('co.nstant.in.cbor') } - module('nightjar-0.2.19.jar', 'com.sparrowwallet.nightjar', '0.2.19') { + module('nightjar-0.2.21.jar', 'com.sparrowwallet.nightjar', '0.2.21') { requires('com.google.common') requires('net.sourceforge.streamsupport') requires('org.slf4j') @@ -459,6 +467,7 @@ extraJavaModuleInfo { requires('com.fasterxml.jackson.core') requires('logback.classic') requires('org.json') + requires('io.reactivex.rxjava2') exports('com.samourai.http.client') exports('com.samourai.tor.client') exports('com.samourai.wallet.api.backend') @@ -466,6 +475,17 @@ extraJavaModuleInfo { exports('com.samourai.wallet.client.indexHandler') exports('com.samourai.wallet.hd') exports('com.samourai.wallet.util') + exports('com.samourai.wallet.bip47.rpc') + exports('com.samourai.wallet.bip47.rpc.java') + exports('com.samourai.wallet.cahoots') + exports('com.samourai.wallet.cahoots.psbt') + exports('com.samourai.wallet.cahoots.stonewallx2') + exports('com.samourai.soroban.cahoots') + exports('com.samourai.soroban.client') + exports('com.samourai.soroban.client.cahoots') + exports('com.samourai.soroban.client.meeting') + exports('com.samourai.soroban.client.rpc') + exports('com.samourai.wallet.send') exports('com.samourai.whirlpool.client.event') exports('com.samourai.whirlpool.client.wallet') exports('com.samourai.whirlpool.client.wallet.beans') diff --git a/drongo b/drongo index 3a061cb7..4a4a62f2 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 3a061cb73ae318fcbe7ea1dcb0b670e78803d9fa +Subproject commit 4a4a62f239f5de1e25e927ee9996326383ea7f89 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 1ac11f17..e49fde98 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -28,6 +28,9 @@ import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.net.ServerType; import com.sparrowwallet.sparrow.preferences.PreferenceGroup; import com.sparrowwallet.sparrow.preferences.PreferencesDialog; +import com.sparrowwallet.sparrow.soroban.CounterpartyDialog; +import com.sparrowwallet.sparrow.soroban.Soroban; +import com.sparrowwallet.sparrow.soroban.SorobanServices; import com.sparrowwallet.sparrow.transaction.TransactionController; import com.sparrowwallet.sparrow.transaction.TransactionData; import com.sparrowwallet.sparrow.transaction.TransactionView; @@ -58,10 +61,7 @@ import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.*; import javafx.scene.layout.StackPane; -import javafx.stage.FileChooser; -import javafx.stage.Stage; -import javafx.stage.StageStyle; -import javafx.stage.Window; +import javafx.stage.*; import javafx.util.Duration; import org.controlsfx.control.Notifications; import org.controlsfx.control.StatusBar; @@ -165,6 +165,9 @@ public class AppController implements Initializable { @FXML private MenuItem sendToMany; + @FXML + private MenuItem findMixingPartner; + @FXML private CheckMenuItem preventSleep; private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty(); @@ -324,6 +327,10 @@ public class AppController implements Initializable { lockWallet.setDisable(true); refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()))); sendToMany.disableProperty().bind(exportWallet.disableProperty()); + findMixingPartner.setDisable(true); + AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { + findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue); + }); setServerType(Config.get().getServerType()); serverToggle.setSelected(isConnected()); @@ -980,6 +987,8 @@ public class AppController implements Initializable { Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId); whirlpool.setScode(wallet.getMasterMixConfig().getScode()); whirlpool.setHDWallet(storage.getWalletId(wallet), copy); + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + soroban.setHDWallet(copy); } StandardAccount standardAccount = wallet.getStandardAccountType(); @@ -1216,6 +1225,70 @@ public class AppController implements Initializable { } } + public void findMixingPartner(ActionEvent event) { + WalletForm selectedWalletForm = getSelectedWalletForm(); + if(selectedWalletForm != null) { + Wallet wallet = selectedWalletForm.getWallet(); + Soroban soroban = AppServices.getSorobanServices().getSoroban(selectedWalletForm.getWalletId()); + if(soroban.getHdWallet() == null) { + if(wallet.isEncrypted()) { + Wallet copy = wallet.copy(); + WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage storage = AppServices.get().getOpenWallets().get(wallet); + Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true); + keyDerivationService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done")); + ECKey encryptionFullKey = keyDerivationService.getValue(); + Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2); + copy.decrypt(key); + + try { + soroban.setHDWallet(copy); + CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet()); + if(Network.get() == Network.TESTNET) { + counterpartyDialog.initModality(Modality.NONE); + } + counterpartyDialog.showAndWait(); + } finally { + key.clear(); + encryptionFullKey.clear(); + password.get().clear(); + } + }); + keyDerivationService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Failed")); + if(keyDerivationService.getException() instanceof InvalidPasswordException) { + Optional optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK); + if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) { + Platform.runLater(() -> findMixingPartner(null)); + } + } else { + log.error("Error deriving wallet key", keyDerivationService.getException()); + } + }); + EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet...")); + keyDerivationService.start(); + } + } else { + soroban.setHDWallet(wallet); + CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet()); + if(Network.get() == Network.TESTNET) { + counterpartyDialog.initModality(Modality.NONE); + } + counterpartyDialog.showAndWait(); + } + } else { + CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet()); + if(Network.get() == Network.TESTNET) { + counterpartyDialog.initModality(Modality.NONE); + } + counterpartyDialog.showAndWait(); + } + } + } + public void minimizeToTray(ActionEvent event) { AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow()); } @@ -1794,6 +1867,7 @@ public class AppController implements Initializable { showLoadingLog.setDisable(true); showUtxosChart.setDisable(true); showTxHex.setDisable(false); + findMixingPartner.setDisable(true); } else if(event instanceof WalletTabSelectedEvent) { WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event; WalletTabData walletTabData = walletTabEvent.getWalletTabData(); @@ -1804,6 +1878,7 @@ public class AppController implements Initializable { showLoadingLog.setDisable(false); showUtxosChart.setDisable(false); showTxHex.setDisable(true); + findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get()); } } } @@ -1828,6 +1903,7 @@ public class AppController implements Initializable { if(selectedWalletForm != null) { if(selectedWalletForm.getWalletId().equals(event.getWalletId())) { exportWallet.setDisable(!event.getWallet().isValid()); + findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get()); } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 2b4dc2b8..1d5e4456 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -16,6 +16,7 @@ import com.sparrowwallet.sparrow.control.TrayManager; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.net.*; +import com.sparrowwallet.sparrow.soroban.SorobanServices; import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; @@ -77,6 +78,8 @@ public class AppServices { private final WhirlpoolServices whirlpoolServices = new WhirlpoolServices(); + private final SorobanServices sorobanServices = new SorobanServices(); + private final MainApp application; private final Map> walletWindows = new LinkedHashMap<>(); @@ -151,6 +154,7 @@ public class AppServices { this.application = application; EventManager.get().register(this); EventManager.get().register(whirlpoolServices); + EventManager.get().register(sorobanServices); } public void start() { @@ -471,6 +475,10 @@ public class AppServices { return get().whirlpoolServices; } + public static SorobanServices getSorobanServices() { + return get().sorobanServices; + } + public static AppController newAppWindow(Stage stage) { try { FXMLLoader appLoader = new FXMLLoader(AppServices.class.getResource("app.fxml")); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/ProgressTimer.java b/src/main/java/com/sparrowwallet/sparrow/control/ProgressTimer.java new file mode 100644 index 00000000..3b462d62 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/ProgressTimer.java @@ -0,0 +1,53 @@ +package com.sparrowwallet.sparrow.control; + +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.scene.control.ProgressIndicator; +import javafx.util.Duration; + +public class ProgressTimer extends ProgressIndicator { + private final IntegerProperty secondsProperty = new SimpleIntegerProperty(60); + + private Timeline timeline; + + public ProgressTimer() { + super(0); + getStyleClass().add("progress-timer"); + } + + public void start() { + start(e -> {}); + } + + public void start(EventHandler onFinished) { + timeline = new Timeline( + new KeyFrame(Duration.ZERO, new KeyValue(progressProperty(), 0)), + new KeyFrame(Duration.seconds(getSeconds() * 0.8), e -> getStyleClass().add("warn")), + new KeyFrame(Duration.seconds(getSeconds()), onFinished, new KeyValue(progressProperty(), 1))); + timeline.setCycleCount(1); + timeline.play(); + } + + public void stop() { + if(timeline != null) { + timeline.stop(); + } + } + + public int getSeconds() { + return secondsProperty.get(); + } + + public IntegerProperty secondsProperty() { + return secondsProperty; + } + + public void setSeconds(int secondsProperty) { + this.secondsProperty.set(secondsProperty); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index 36c0b343..8e23a5e9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -10,9 +10,14 @@ import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent; import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent; +import com.sparrowwallet.sparrow.event.SorobanInitiatedEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.soroban.SorobanServices; +import com.sparrowwallet.sparrow.wallet.OptimizationStrategy; import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Group; @@ -42,6 +47,7 @@ public class TransactionDiagram extends GridPane { private WalletTransaction walletTx; private final BooleanProperty finalProperty = new SimpleBooleanProperty(false); + private final ObjectProperty optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY); public void update(WalletTransaction walletTx) { setMinHeight(getDiagramHeight()); @@ -112,6 +118,14 @@ public class TransactionDiagram extends GridPane { displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, walletTx.getSelectedUtxoSets().size())); } + if(getOptimizationStrategy() == OptimizationStrategy.PRIVACY && displayedUtxoSets.size() == 1 && SorobanServices.canWalletMix(walletTx.getWallet()) + && walletTx.getPayments().size() == 1 + && (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getAddress(walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE)).getScriptType())) { + Map addUserUtxoSet = new HashMap<>(); + addUserUtxoSet.put(new AddUserBlockTransactionHashIndex(), null); + displayedUtxoSets.add(addUserUtxoSet); + } + List> paddedUtxoSets = new ArrayList<>(); int maxDisplayedSetSize = displayedUtxoSets.stream().mapToInt(Map::size).max().orElse(0); for(Map selectedUtxoSet : displayedUtxoSets) { @@ -189,8 +203,18 @@ public class TransactionDiagram extends GridPane { if(numSets > 1) { double setHeight = (height / numSets) - 5; for(int set = 0; set < numSets; set++) { - StackPane stackPane = getBracket(width, setHeight, getUserGlyph(), walletTx.getWallet().getFullDisplayName() + "\nClick to replace with an external contributor"); - allBrackets.getChildren().add(stackPane); + boolean externalUserSet = displayedUtxoSets.get(set).values().stream().anyMatch(Objects::nonNull); + boolean addUserSet = displayedUtxoSets.get(set).keySet().stream().anyMatch(ref -> ref instanceof AddUserBlockTransactionHashIndex); + if(externalUserSet || addUserSet) { + boolean replace = !isFinal() && set > 0 && SorobanServices.canWalletMix(walletTx.getWallet()); + Glyph bracketGlyph = !replace && walletTx.isCoinControlUsed() ? getLockGlyph() : (addUserSet ? getUserAddGlyph() : getCoinsGlyph(replace)); + String tooltipText = addUserSet ? "Click to add a mixing partner" : (walletTx.getWallet().getFullDisplayName() + (replace ? "\nClick to replace with a mixing partner" : "")); + StackPane stackPane = getBracket(width, setHeight, bracketGlyph, tooltipText); + allBrackets.getChildren().add(stackPane); + } else { + StackPane stackPane = getBracket(width, setHeight, getUserGlyph(), "Mixing partner"); + allBrackets.getChildren().add(stackPane); + } } } else if(walletTx.isCoinControlUsed()) { StackPane stackPane = getBracket(width, height, getLockGlyph(), "Coin control active"); @@ -248,6 +272,7 @@ public class TransactionDiagram extends GridPane { glyph.getStyleClass().add("inputs-type"); Tooltip tooltip = new Tooltip(tooltipText); + tooltip.getStyleClass().add("transaction-tooltip"); tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); tooltip.setShowDuration(Duration.INDEFINITE); glyph.setTooltip(tooltip); @@ -299,7 +324,7 @@ public class TransactionDiagram extends GridPane { joiner.add(getInputDescription(additionalInput)); } tooltip.setText(joiner.toString()); - } else if(input instanceof InvisibleBlockTransactionHashIndex) { + } else if(input instanceof InvisibleBlockTransactionHashIndex || input instanceof AddUserBlockTransactionHashIndex) { tooltip.setText(""); } else { if(walletTx.getInputTransactions() != null && walletTx.getInputTransactions().get(input.getHash()) != null) { @@ -359,7 +384,7 @@ public class TransactionDiagram extends GridPane { CubicCurve curve = new CubicCurve(); curve.getStyleClass().add("input-line"); - if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) { + if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex || inputs.get(numUtxos-i) instanceof AddUserBlockTransactionHashIndex) { curve.getStyleClass().add("input-dashed-line"); } else if(inputs.get(numUtxos-i) instanceof InvisibleBlockTransactionHashIndex) { continue; @@ -462,7 +487,7 @@ public class TransactionDiagram extends GridPane { Glyph outputGlyph = getOutputGlyph(payment); boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment; payment.setLabel(getOutputLabel(payment)); - Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph); + Label recipientLabel = new Label(payment.getLabel() == null || payment.getType() == Payment.Type.FAKE_MIX || payment.getType() == Payment.Type.MIX ? payment.getAddress().toString().substring(0, 8) + "..." : payment.getLabel(), outputGlyph); recipientLabel.getStyleClass().add("output-label"); recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label"); Wallet toWallet = getToWallet(payment); @@ -625,7 +650,9 @@ public class TransactionDiagram extends GridPane { } public Glyph getOutputGlyph(Payment payment) { - if(payment.getType().equals(Payment.Type.FAKE_MIX)) { + if(payment.getType().equals(Payment.Type.MIX)) { + return getMixGlyph(); + } else if(payment.getType().equals(Payment.Type.FAKE_MIX)) { return getFakeMixGlyph(); } else if(walletTx.isConsolidationSend(payment)) { return getConsolidationGlyph(); @@ -704,9 +731,9 @@ public class TransactionDiagram extends GridPane { return getChangeGlyph(); } - public static Glyph getPayjoinGlyph() { + public static Glyph getMixGlyph() { Glyph payjoinGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM); - payjoinGlyph.getStyleClass().add("payjoin-icon"); + payjoinGlyph.getStyleClass().add("mix-icon"); payjoinGlyph.setFontSize(12); return payjoinGlyph; } @@ -754,14 +781,51 @@ public class TransactionDiagram extends GridPane { } private Glyph getUserGlyph() { - Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS); + Glyph userGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER); userGlyph.getStyleClass().add("user-icon"); userGlyph.setFontSize(12); - userGlyph.setOnMouseEntered(event -> userGlyph.setIcon(FontAwesome5.Glyph.USER_PLUS)); - userGlyph.setOnMouseExited(event -> userGlyph.setIcon(FontAwesome5.Glyph.COINS)); return userGlyph; } + private Glyph getUserAddGlyph() { + Glyph userAddGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER_PLUS); + userAddGlyph.getStyleClass().add("useradd-icon"); + userAddGlyph.setFontSize(12); + userAddGlyph.setOnMouseEntered(event -> { + userAddGlyph.setFontSize(18); + }); + userAddGlyph.setOnMouseExited(event -> { + userAddGlyph.setFontSize(12); + }); + userAddGlyph.setOnMouseClicked(event -> { + EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet())); + }); + return userAddGlyph; + } + + private Glyph getCoinsGlyph(boolean allowReplacement) { + Glyph coinsGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS); + coinsGlyph.setFontSize(12); + if(allowReplacement) { + coinsGlyph.getStyleClass().add("coins-replace-icon"); + coinsGlyph.setOnMouseEntered(event -> { + coinsGlyph.setIcon(FontAwesome5.Glyph.USER_PLUS); + coinsGlyph.setFontSize(18); + }); + coinsGlyph.setOnMouseExited(event -> { + coinsGlyph.setIcon(FontAwesome5.Glyph.COINS); + coinsGlyph.setFontSize(12); + }); + coinsGlyph.setOnMouseClicked(event -> { + EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet())); + }); + } else { + coinsGlyph.getStyleClass().add("coins-icon"); + } + + return coinsGlyph; + } + public boolean isFinal() { return finalProperty.get(); } @@ -774,6 +838,18 @@ public class TransactionDiagram extends GridPane { this.finalProperty.set(isFinal); } + public OptimizationStrategy getOptimizationStrategy() { + return optimizationStrategyProperty.get(); + } + + public ObjectProperty optimizationStrategyProperty() { + return optimizationStrategyProperty; + } + + public void setOptimizationStrategy(OptimizationStrategy optimizationStrategy) { + this.optimizationStrategyProperty.set(optimizationStrategy); + } + private static class PayjoinBlockTransactionHashIndex extends BlockTransactionHashIndex { public PayjoinBlockTransactionHashIndex() { super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0); @@ -814,6 +890,17 @@ public class TransactionDiagram extends GridPane { } } + private static class AddUserBlockTransactionHashIndex extends BlockTransactionHashIndex { + public AddUserBlockTransactionHashIndex() { + super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0); + } + + @Override + public String getLabel() { + return "Add Mixing Partner?"; + } + } + private static class AdditionalPayment extends Payment { private final List additionalPayments; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SorobanInitiatedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SorobanInitiatedEvent.java new file mode 100644 index 00000000..f4abe67a --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/SorobanInitiatedEvent.java @@ -0,0 +1,15 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.wallet.Wallet; + +public class SorobanInitiatedEvent { + private Wallet wallet; + + public SorobanInitiatedEvent(Wallet wallet) { + this.wallet = wallet; + } + + public Wallet getWallet() { + return wallet; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index e4a502ff..1a4acbf4 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -73,6 +73,7 @@ public class FontAwesome5 extends GlyphFont { USER('\uf007'), USER_FRIENDS('\uf500'), USER_PLUS('\uf234'), + USER_SLASH('\uf506'), WALLET('\uf555'), WEIGHT('\uf496'); diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java new file mode 100644 index 00000000..61188386 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java @@ -0,0 +1,310 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.samourai.soroban.cahoots.CahootsContext; +import com.samourai.soroban.client.cahoots.OnlineCahootsMessage; +import com.samourai.soroban.client.cahoots.SorobanCahootsService; +import com.samourai.wallet.bip47.rpc.PaymentCode; +import com.samourai.wallet.cahoots.Cahoots; +import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.psbt.PSBTParseException; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.control.CopyableTextField; +import com.sparrowwallet.sparrow.control.ProgressTimer; +import com.sparrowwallet.sparrow.control.TransactionDiagram; +import io.reactivex.rxjavafx.schedulers.JavaFxScheduler; +import io.reactivex.schedulers.Schedulers; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.fxml.FXML; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.VBox; +import javafx.util.StringConverter; +import org.controlsfx.glyphfont.Glyph; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS; + +public class CounterpartyController extends SorobanController { + private static final Logger log = LoggerFactory.getLogger(CounterpartyController.class); + + private String walletId; + private Wallet wallet; + + @FXML + private VBox step1; + + @FXML + private VBox step2; + + @FXML + private VBox step3; + + @FXML + private VBox step4; + + @FXML + private CopyableTextField paymentCode; + + @FXML + private ComboBox mixWallet; + + @FXML + private ProgressTimer step2Timer; + + @FXML + private Label step2Desc; + + @FXML + private Label mixingPartner; + + @FXML + private Label meetingFail; + + @FXML + private VBox mixDetails; + + @FXML + private Label mixType; + + @FXML + private ProgressTimer step3Timer; + + @FXML + private Label step3Desc; + + @FXML + private ProgressBar sorobanProgressBar; + + @FXML + private Label sorobanProgressLabel; + + @FXML + private Glyph mixDeclined; + + @FXML + private TransactionDiagram transactionDiagram; + + private final ObjectProperty meetingReceived = new SimpleObjectProperty<>(null); + + private final ObjectProperty meetingAccepted = new SimpleObjectProperty<>(null); + + private final ObjectProperty transactionProperty = new SimpleObjectProperty<>(null); + + public void initializeView(String walletId, Wallet wallet) { + this.walletId = walletId; + this.wallet = wallet; + + step1.managedProperty().bind(step1.visibleProperty()); + step2.managedProperty().bind(step2.visibleProperty()); + step3.managedProperty().bind(step3.visibleProperty()); + step4.managedProperty().bind(step4.visibleProperty()); + + mixWallet.setConverter(new StringConverter<>() { + @Override + public String toString(Wallet wallet) { + return wallet == null ? "" : wallet.getFullDisplayName(); + } + + @Override + public Wallet fromString(String string) { + return null; + } + }); + mixWallet.setItems(FXCollections.observableList(wallet.getAllWallets())); + mixWallet.setValue(wallet); + mixWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> setWallet(selectedWallet)); + + sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty()); + sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty()); + mixDeclined.managedProperty().bind(mixDeclined.visibleProperty()); + sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty()); + mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not()); + step2Timer.visibleProperty().bind(mixingPartner.visibleProperty()); + step3Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty()); + + step2.setVisible(false); + step3.setVisible(false); + step4.setVisible(false); + + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + if(soroban.getHdWallet() == null) { + throw new IllegalStateException("Soroban HD wallet must be set"); + } + + paymentCode.setText(soroban.getPaymentCode().toString()); + + mixingPartner.managedProperty().bind(mixingPartner.visibleProperty()); + meetingFail.managedProperty().bind(meetingFail.visibleProperty()); + meetingFail.visibleProperty().bind(mixingPartner.visibleProperty().not()); + + mixDetails.managedProperty().bind(mixDetails.visibleProperty()); + mixDetails.setVisible(false); + + meetingAccepted.addListener((observable, oldValue, accepted) -> { + Platform.exitNestedEventLoop(meetingAccepted, accepted); + meetingReceived.set(null); + }); + + step2.visibleProperty().addListener((observable, oldValue, visible) -> { + if(visible) { + startCounterpartyMeetingReceive(); + step2Timer.start(e -> { + step2Desc.setText("Mix declined due to timeout."); + meetingReceived.set(Boolean.FALSE); + }); + } + }); + + step3.visibleProperty().addListener((observable, oldValue, visible) -> { + if(visible) { + meetingAccepted.set(Boolean.TRUE); + } + }); + } + + private void setWallet(Wallet wallet) { + this.walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet); + this.wallet = wallet; + } + + private void startCounterpartyMeetingReceive() { + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + SparrowCahootsWallet counterpartyCahootsWallet = soroban.getCahootsWallet(wallet, 1); + + try { + SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(counterpartyCahootsWallet); + sorobanMeetingService.receiveMeetingRequest(TIMEOUT_MS) + .subscribeOn(Schedulers.io()) + .observeOn(JavaFxScheduler.platform()) + .subscribe(requestMessage -> { + PaymentCode paymentCodeInitiator = new PaymentCode(requestMessage.getSender()); + mixingPartner.setText(requestMessage.getSender()); + mixType.setText(requestMessage.getType().getLabel()); + mixDetails.setVisible(true); + meetingReceived.set(Boolean.TRUE); + Boolean accepted = (Boolean)Platform.enterNestedEventLoop(meetingAccepted); + sorobanMeetingService.sendMeetingResponse(paymentCodeInitiator, requestMessage, accepted) + .subscribeOn(Schedulers.io()) + .observeOn(JavaFxScheduler.platform()) + .subscribe(responseMessage -> { + if(accepted) { + startCounterpartyStonewall(counterpartyCahootsWallet, paymentCodeInitiator); + } + }, error -> { + log.error("Error sending meeting response", error); + mixingPartner.setVisible(false); + }); + }, error -> { + log.error("Failed to receive meeting request", error); + mixingPartner.setVisible(false); + }); + } catch(Exception e) { + log.error("Error sending meeting response", e); + } + } + + private void startCounterpartyStonewall(SparrowCahootsWallet counterpartyCahootsWallet, PaymentCode initiatorPaymentCode) { + sorobanProgressLabel.setText("Creating mix transaction..."); + + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + Map walletUtxos = wallet.getWalletUtxos(); + for(Map.Entry entry : walletUtxos.entrySet()) { + counterpartyCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex()); + } + + try { + SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(counterpartyCahootsWallet); + CahootsContext cahootsContext = CahootsContext.newCounterpartyStonewallx2(); + sorobanCahootsService.contributor(counterpartyCahootsWallet.getAccount(), cahootsContext, initiatorPaymentCode, TIMEOUT_MS) + .subscribeOn(Schedulers.io()) + .observeOn(JavaFxScheduler.platform()) + .subscribe(sorobanMessage -> { + OnlineCahootsMessage cahootsMessage = (OnlineCahootsMessage)sorobanMessage; + if(cahootsMessage != null) { + Cahoots cahoots = cahootsMessage.getCahoots(); + sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5); + + if(cahoots.getStep() == 3) { + sorobanProgressLabel.setText("Your mix partner is reviewing the transaction..."); + step3Timer.start(); + } else if(cahoots.getStep() >= 4 && cahoots instanceof STONEWALLx2 stonewallx2) { + try { + Transaction transaction = getTransaction(stonewallx2); + if(transaction != null) { + transactionProperty.set(transaction); + updateTransactionDiagram(transactionDiagram, wallet, null, transaction); + next(); + } + } catch(PSBTParseException e) { + log.error("Invalid Stonewallx2 PSBT created", e); + step3Desc.setText("Invalid transaction created."); + sorobanProgressLabel.setVisible(false); + } + } + } + }, error -> { + log.error("Error creating mix transaction", error); + String cutFrom = "Exception: "; + int index = error.getMessage().lastIndexOf(cutFrom); + String msg = index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length()); + msg = msg.replace("#Cahoots", "mix transaction"); + step3Desc.setText(msg); + sorobanProgressLabel.setVisible(false); + }); + } catch(Exception e) { + log.error("Error creating mix transaction", e); + sorobanProgressLabel.setText(e.getMessage()); + } + } + + public boolean next() { + if(step1.isVisible()) { + step1.setVisible(false); + step2.setVisible(true); + return true; + } + + if(step2.isVisible()) { + step2.setVisible(false); + step3.setVisible(true); + return true; + } + + if(step3.isVisible()) { + step3.setVisible(false); + step4.setVisible(true); + return true; + } + + return false; + } + + public void cancel() { + meetingAccepted.set(Boolean.FALSE); + } + + public ObjectProperty meetingReceivedProperty() { + return meetingReceived; + } + + public ObjectProperty meetingAcceptedProperty() { + return meetingAccepted; + } + + public ObjectProperty transactionProperty() { + return transactionProperty; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyDialog.java b/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyDialog.java new file mode 100644 index 00000000..b003173b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyDialog.java @@ -0,0 +1,73 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import javafx.event.ActionEvent; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.*; + +import java.io.IOException; + +public class CounterpartyDialog extends Dialog { + public CounterpartyDialog(String walletId, Wallet wallet) { + final DialogPane dialogPane = getDialogPane(); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + AppServices.onEscapePressed(dialogPane.getScene(), this::close); + + try { + FXMLLoader counterpartyLoader = new FXMLLoader(AppServices.class.getResource("soroban/counterparty.fxml")); + dialogPane.setContent(counterpartyLoader.load()); + CounterpartyController counterpartyController = counterpartyLoader.getController(); + counterpartyController.initializeView(walletId, wallet); + + dialogPane.setPrefWidth(730); + dialogPane.setPrefHeight(520); + AppServices.moveToActiveWindowScreen(this); + + dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm()); + dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/counterparty.css").toExternalForm()); + + final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE); + final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.APPLY); + dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, doneButtonType); + + Button nextButton = (Button)dialogPane.lookupButton(nextButtonType); + Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType); + Button doneButton = (Button)dialogPane.lookupButton(doneButtonType); + doneButton.setDisable(true); + counterpartyController.meetingReceivedProperty().addListener((observable, oldValue, newValue) -> { + nextButton.setDisable(newValue != Boolean.TRUE); + }); + counterpartyController.transactionProperty().addListener((observable, oldValue, newValue) -> { + nextButton.setVisible(false); + doneButton.setDisable(newValue == null); + cancelButton.setDisable(newValue != null); + }); + + nextButton.managedProperty().bind(nextButton.visibleProperty()); + doneButton.managedProperty().bind(doneButton.visibleProperty()); + + doneButton.visibleProperty().bind(nextButton.visibleProperty().not()); + + nextButton.addEventFilter(ActionEvent.ACTION, event -> { + if(!counterpartyController.next()) { + nextButton.setVisible(false); + doneButton.setDefaultButton(true); + } + nextButton.setDisable(counterpartyController.meetingReceivedProperty().get() != Boolean.TRUE); + event.consume(); + }); + + cancelButton.addEventFilter(ActionEvent.ACTION, event -> { + if(counterpartyController.meetingReceivedProperty().get() == Boolean.TRUE) { + counterpartyController.cancel(); + } + }); + + setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY)); + } catch(IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java new file mode 100644 index 00000000..85d49523 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java @@ -0,0 +1,333 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.samourai.soroban.cahoots.CahootsContext; +import com.samourai.soroban.client.cahoots.OnlineCahootsMessage; +import com.samourai.soroban.client.cahoots.SorobanCahootsService; +import com.samourai.wallet.bip47.rpc.PaymentCode; +import com.samourai.wallet.cahoots.Cahoots; +import com.samourai.wallet.cahoots.CahootsType; +import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2; +import com.sparrowwallet.drongo.SecureString; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.EncryptionType; +import com.sparrowwallet.drongo.crypto.InvalidPasswordException; +import com.sparrowwallet.drongo.crypto.Key; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.psbt.PSBTParseException; +import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.control.ProgressTimer; +import com.sparrowwallet.sparrow.control.TransactionDiagram; +import com.sparrowwallet.sparrow.control.WalletPasswordDialog; +import com.sparrowwallet.sparrow.event.StorageEvent; +import com.sparrowwallet.sparrow.event.TimedEvent; +import com.sparrowwallet.sparrow.io.Storage; +import io.reactivex.rxjavafx.schedulers.JavaFxScheduler; +import io.reactivex.schedulers.Schedulers; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.TextField; +import javafx.scene.layout.VBox; +import org.controlsfx.glyphfont.Glyph; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; +import static com.sparrowwallet.sparrow.soroban.Soroban.TIMEOUT_MS; + +public class InitiatorController extends SorobanController { + private static final Logger log = LoggerFactory.getLogger(InitiatorController.class); + + private String walletId; + private Wallet wallet; + private WalletTransaction walletTransaction; + + @FXML + private VBox step1; + + @FXML + private VBox step2; + + @FXML + private VBox step3; + + @FXML + private TextField counterparty; + + @FXML + private ProgressTimer step2Timer; + + @FXML + private Label step2Desc; + + @FXML + private ProgressBar sorobanProgressBar; + + @FXML + private Label sorobanProgressLabel; + + @FXML + private Glyph mixDeclined; + + @FXML + private ProgressTimer step3Timer; + + @FXML + private Label step3Desc; + + @FXML + private TransactionDiagram transactionDiagram; + + private final ObjectProperty stepProperty = new SimpleObjectProperty<>(Step.SETUP); + + private final ObjectProperty transactionAccepted = new SimpleObjectProperty<>(null); + + private final ObjectProperty transactionProperty = new SimpleObjectProperty<>(null); + + public void initializeView(String walletId, Wallet wallet, WalletTransaction walletTransaction) { + this.walletId = walletId; + this.wallet = wallet; + this.walletTransaction = walletTransaction; + + step1.managedProperty().bind(step1.visibleProperty()); + step2.managedProperty().bind(step2.visibleProperty()); + step3.managedProperty().bind(step3.visibleProperty()); + + sorobanProgressBar.managedProperty().bind(sorobanProgressBar.visibleProperty()); + sorobanProgressLabel.managedProperty().bind(sorobanProgressLabel.visibleProperty()); + mixDeclined.managedProperty().bind(mixDeclined.visibleProperty()); + sorobanProgressBar.visibleProperty().bind(sorobanProgressLabel.visibleProperty()); + mixDeclined.visibleProperty().bind(sorobanProgressLabel.visibleProperty().not()); + step2Timer.visibleProperty().bind(sorobanProgressLabel.visibleProperty()); + + step2.setVisible(false); + step3.setVisible(false); + + transactionAccepted.addListener((observable, oldValue, accepted) -> { + if(transactionProperty.get() != null) { + Platform.exitNestedEventLoop(transactionAccepted, accepted); + } + }); + + transactionProperty.addListener((observable, oldValue, transaction) -> { + if(transaction != null) { + updateTransactionDiagram(transactionDiagram, wallet, walletTransaction, transaction); + } + }); + + step2.visibleProperty().addListener((observable, oldValue, visible) -> { + if(visible) { + startInitiatorMeetingRequest(); + step2Timer.start(); + } + }); + } + + private void startInitiatorMeetingRequest() { + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + if(soroban.getHdWallet() == null) { + if(wallet.isEncrypted()) { + Wallet copy = wallet.copy(); + WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage storage = AppServices.get().getOpenWallets().get(wallet); + Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true); + keyDerivationService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done")); + ECKey encryptionFullKey = keyDerivationService.getValue(); + Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2); + copy.decrypt(key); + + try { + soroban.setHDWallet(copy); + startInitiatorMeetingRequest(soroban, wallet); + } finally { + key.clear(); + encryptionFullKey.clear(); + password.get().clear(); + } + }); + keyDerivationService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed")); + if(keyDerivationService.getException() instanceof InvalidPasswordException) { + Optional optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK); + if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) { + Platform.runLater(this::startInitiatorMeetingRequest); + } + } else { + log.error("Error deriving wallet key", keyDerivationService.getException()); + } + }); + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet...")); + keyDerivationService.start(); + } else { + step2.setVisible(false); + step1.setVisible(true); + } + } else { + soroban.setHDWallet(wallet); + startInitiatorMeetingRequest(soroban, wallet); + } + } else { + startInitiatorMeetingRequest(soroban, wallet); + } + } + + private void startInitiatorMeetingRequest(Soroban soroban, Wallet wallet) { + SparrowCahootsWallet initiatorCahootsWallet = soroban.getCahootsWallet(wallet, (long)walletTransaction.getFeeRate()); + PaymentCode paymentCodeCounterparty = new PaymentCode(counterparty.getText()); + + try { + SorobanCahootsService sorobanMeetingService = soroban.getSorobanCahootsService(initiatorCahootsWallet); + sorobanMeetingService.sendMeetingRequest(paymentCodeCounterparty, CahootsType.STONEWALLX2) + .subscribeOn(Schedulers.io()) + .observeOn(JavaFxScheduler.platform()) + .subscribe(meetingRequest -> { + sorobanProgressLabel.setText("Waiting for mixing partner..."); + sorobanMeetingService.receiveMeetingResponse(paymentCodeCounterparty, meetingRequest, TIMEOUT_MS) + .subscribeOn(Schedulers.io()) + .observeOn(JavaFxScheduler.platform()) + .subscribe(sorobanResponse -> { + if(sorobanResponse.isAccept()) { + sorobanProgressBar.setProgress(0.1); + sorobanProgressLabel.setText("Mixing partner accepted!"); + startInitiatorStonewall(initiatorCahootsWallet, paymentCodeCounterparty); + } else { + step2Desc.setText("Mixing partner declined."); + sorobanProgressLabel.setVisible(false); + } + }, error -> { + log.error("Error receiving meeting response", error); + String cutFrom = "Exception: "; + int index = error.getMessage().lastIndexOf(cutFrom); + step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length())); + sorobanProgressLabel.setVisible(false); + }); + }, error -> { + log.error("Error sending meeting request", error); + step2Desc.setText(error.getMessage()); + sorobanProgressLabel.setVisible(false); + }); + } catch(Exception e) { + log.error("Error sending meeting request", e); + } + } + + private void startInitiatorStonewall(SparrowCahootsWallet initiatorCahootsWallet, PaymentCode paymentCodeCounterparty) { + Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId); + + Payment payment = walletTransaction.getPayments().get(0); + Map firstSetUtxos = walletTransaction.getSelectedUtxoSets().get(0); + for(Map.Entry entry : firstSetUtxos.entrySet()) { + initiatorCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex()); + } + + SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet); + CahootsContext cahootsContext = CahootsContext.newInitiatorStonewallx2(payment.getAmount(), payment.getAddress().toString()); + + sorobanCahootsService.getSorobanService().getOnInteraction() + .observeOn(JavaFxScheduler.platform()) + .subscribe(interaction -> { + Boolean accepted = (Boolean)Platform.enterNestedEventLoop(transactionAccepted); + if(accepted) { + interaction.sorobanAccept(); + } else { + interaction.sorobanReject("Mixing partner declined to broadcast the transaction."); + } + }); + + try { + sorobanCahootsService.initiator(initiatorCahootsWallet.getAccount(), cahootsContext, paymentCodeCounterparty, TIMEOUT_MS) + .subscribeOn(Schedulers.io()) + .observeOn(JavaFxScheduler.platform()) + .subscribe(sorobanMessage -> { + OnlineCahootsMessage cahootsMessage = (OnlineCahootsMessage)sorobanMessage; + if(cahootsMessage != null) { + Cahoots cahoots = cahootsMessage.getCahoots(); + sorobanProgressBar.setProgress((double)(cahoots.getStep() + 1) / 5); + + if(cahoots.getStep() >= 3 && cahoots instanceof STONEWALLx2 stonewallx2) { + try { + Transaction transaction = getTransaction(stonewallx2); + if(transaction != null) { + transactionProperty.set(transaction); + if(cahoots.getStep() == 3) { + next(); + step3Timer.start(e -> { + if(stepProperty.get() != Step.BROADCAST) { + step3Desc.setText("Transaction declined due to timeout."); + transactionAccepted.set(Boolean.FALSE); + } + }); + } else if(cahoots.getStep() == 4) { + stepProperty.set(Step.BROADCAST); + } + } + } catch(PSBTParseException e) { + log.error("Invalid Stonewallx2 PSBT created", e); + step2Desc.setText("Invalid transaction created."); + sorobanProgressLabel.setVisible(false); + } + } + } + }, + error -> { + log.error("Error creating mix transaction", error); + String cutFrom = "Exception: "; + int index = error.getMessage().lastIndexOf(cutFrom); + step2Desc.setText(index < 0 ? error.getMessage() : error.getMessage().substring(index + cutFrom.length())); + sorobanProgressLabel.setVisible(false); + }); + } catch(Exception e) { + log.error("Soroban communication error", e); + } + } + + public void next() { + if(step1.isVisible()) { + step1.setVisible(false); + step2.setVisible(true); + stepProperty.set(Step.COMMUNICATE); + return; + } + + if(step2.isVisible()) { + step2.setVisible(false); + step3.setVisible(true); + stepProperty.set(Step.REVIEW); + } + } + + public void accept() { + transactionAccepted.set(Boolean.TRUE); + } + + public void cancel() { + transactionAccepted.set(Boolean.FALSE); + } + + public ObjectProperty stepProperty() { + return stepProperty; + } + + public Transaction getTransaction() { + return transactionProperty.get(); + } + + public ObjectProperty transactionAcceptedProperty() { + return transactionAccepted; + } + + public enum Step { + SETUP, COMMUNICATE, REVIEW, BROADCAST + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java new file mode 100644 index 00000000..b431e933 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorDialog.java @@ -0,0 +1,120 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.sparrowwallet.drongo.SecureString; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletTransaction; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.control.WalletPasswordDialog; +import com.sparrowwallet.sparrow.event.StorageEvent; +import com.sparrowwallet.sparrow.event.TimedEvent; +import com.sparrowwallet.sparrow.io.Storage; +import javafx.event.ActionEvent; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.*; + +import java.io.IOException; +import java.util.Optional; + +public class InitiatorDialog extends Dialog { + private final boolean confirmationRequired; + + public InitiatorDialog(String walletId, Wallet wallet, WalletTransaction walletTransaction) { + this.confirmationRequired = AppServices.getSorobanServices().getSoroban(walletId).getHdWallet() != null; + + final DialogPane dialogPane = getDialogPane(); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + AppServices.onEscapePressed(dialogPane.getScene(), this::close); + + try { + FXMLLoader initiatorLoader = new FXMLLoader(AppServices.class.getResource("soroban/initiator.fxml")); + dialogPane.setContent(initiatorLoader.load()); + InitiatorController initiatorController = initiatorLoader.getController(); + initiatorController.initializeView(walletId, wallet, walletTransaction); + + dialogPane.setPrefWidth(730); + dialogPane.setPrefHeight(520); + AppServices.moveToActiveWindowScreen(this); + + dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm()); + dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/initiator.css").toExternalForm()); + + final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE); + final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + final ButtonType broadcastButtonType = new javafx.scene.control.ButtonType("Sign & Broadcast", ButtonBar.ButtonData.APPLY); + dialogPane.getButtonTypes().addAll(nextButtonType, cancelButtonType, broadcastButtonType); + + Button nextButton = (Button)dialogPane.lookupButton(nextButtonType); + Button cancelButton = (Button)dialogPane.lookupButton(cancelButtonType); + Button broadcastButton = (Button)dialogPane.lookupButton(broadcastButtonType); + broadcastButton.setDisable(true); + + nextButton.managedProperty().bind(nextButton.visibleProperty()); + broadcastButton.managedProperty().bind(broadcastButton.visibleProperty()); + + broadcastButton.visibleProperty().bind(nextButton.visibleProperty().not()); + + initiatorController.stepProperty().addListener((observable, oldValue, step) -> { + if(step == InitiatorController.Step.SETUP) { + nextButton.setDisable(false); + nextButton.setVisible(true); + } else if(step == InitiatorController.Step.COMMUNICATE) { + nextButton.setDisable(true); + nextButton.setVisible(true); + } else if(step == InitiatorController.Step.REVIEW) { + nextButton.setVisible(false); + broadcastButton.setDefaultButton(true); + broadcastButton.setDisable(false); + } else if(step == InitiatorController.Step.BROADCAST) { + setResult(initiatorController.getTransaction()); + } + }); + + initiatorController.transactionAcceptedProperty().addListener((observable, oldValue, accepted) -> { + broadcastButton.setDisable(accepted != Boolean.TRUE); + }); + + nextButton.addEventFilter(ActionEvent.ACTION, event -> { + initiatorController.next(); + event.consume(); + }); + + cancelButton.addEventFilter(ActionEvent.ACTION, event -> { + initiatorController.cancel(); + }); + + broadcastButton.addEventFilter(ActionEvent.ACTION, event -> { + acceptAndBroadcast(initiatorController, walletId, wallet); + event.consume(); + }); + + setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY) ? initiatorController.getTransaction() : null); + } catch(IOException e) { + throw new RuntimeException(e); + } + } + + private void acceptAndBroadcast(InitiatorController initiatorController, String walletId, Wallet wallet) { + if(confirmationRequired && wallet.isEncrypted()) { + WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage storage = AppServices.get().getOpenWallets().get(wallet); + Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true); + keyDerivationService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done")); + initiatorController.accept(); + password.get().clear(); + }); + keyDerivationService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed")); + }); + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet...")); + keyDerivationService.start(); + } + } else { + initiatorController.accept(); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java b/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java new file mode 100644 index 00000000..b0de39aa --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/Soroban.java @@ -0,0 +1,144 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.google.common.net.HostAndPort; +import com.samourai.http.client.HttpUsage; +import com.samourai.http.client.IHttpClient; +import com.samourai.soroban.client.SorobanServer; +import com.samourai.soroban.client.cahoots.SorobanCahootsService; +import com.samourai.soroban.client.rpc.RpcClient; +import com.samourai.wallet.bip47.rpc.BIP47Wallet; +import com.samourai.wallet.bip47.rpc.PaymentCode; +import com.samourai.wallet.bip47.rpc.java.Bip47UtilJava; +import com.samourai.wallet.cahoots.CahootsWallet; +import com.samourai.wallet.hd.HD_Wallet; +import com.samourai.wallet.hd.HD_WalletFactoryGeneric; +import com.sparrowwallet.drongo.Drongo; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.nightjar.http.JavaHttpClientService; +import com.sparrowwallet.sparrow.AppServices; +import javafx.concurrent.Service; +import javafx.concurrent.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.Provider; +import java.util.ArrayList; +import java.util.List; + +public class Soroban { + private static final Logger log = LoggerFactory.getLogger(Soroban.class); + + protected static final Bip47UtilJava bip47Util = Bip47UtilJava.getInstance(); + protected static final Provider PROVIDER_JAVA = Drongo.getProvider(); + protected static final int TIMEOUT_MS = 60000; + public static final List SOROBAN_NETWORKS = List.of(Network.MAINNET, Network.TESTNET); + + private final SorobanServer sorobanServer; + private final JavaHttpClientService httpClientService; + + private HD_Wallet hdWallet; + private PaymentCode paymentCode; + + public Soroban(Network network, HostAndPort torProxy) { + this.sorobanServer = SorobanServer.valueOf(network.getName().toUpperCase()); + this.httpClientService = new JavaHttpClientService(torProxy); + } + + public HD_Wallet getHdWallet() { + return hdWallet; + } + + public PaymentCode getPaymentCode() { + return paymentCode; + } + + public void setHDWallet(Wallet wallet) { + if(wallet.isEncrypted()) { + throw new IllegalStateException("Wallet cannot be encrypted"); + } + + try { + Keystore keystore = wallet.getKeystores().get(0); + ScriptType scriptType = wallet.getScriptType(); + int purpose = scriptType.getDefaultDerivation().get(0).num(); + List words = keystore.getSeed().getMnemonicCode(); + String passphrase = keystore.getSeed().getPassphrase().asString(); + HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance(); + byte[] seed = hdWalletFactory.computeSeedFromWords(words); + hdWallet = new HD_Wallet(purpose, new ArrayList<>(words), sorobanServer.getParams(), seed, passphrase); + BIP47Wallet bip47w = hdWalletFactory.getBIP47(hdWallet.getSeedHex(), hdWallet.getPassphrase(), sorobanServer.getParams()); + paymentCode = bip47Util.getPaymentCode(bip47w); + } catch(Exception e) { + throw new IllegalStateException("Could not create Soroban HD wallet ", e); + } + } + + public SparrowCahootsWallet getCahootsWallet(Wallet wallet, double feeRate) { + if(wallet.getScriptType() != ScriptType.P2WPKH) { + throw new IllegalArgumentException("Wallet must be P2WPKH"); + } + + if(hdWallet == null) { + for(Wallet associatedWallet : wallet.getAllWallets()) { + Soroban soroban = AppServices.getSorobanServices().getSoroban(associatedWallet); + if(soroban != null && soroban.getHdWallet() != null) { + hdWallet = soroban.hdWallet; + paymentCode = soroban.paymentCode; + } + } + } + + if(hdWallet == null) { + throw new IllegalStateException("HD wallet is not set"); + } + + try { + return new SparrowCahootsWallet(wallet, hdWallet, sorobanServer, (long)feeRate); + } catch(Exception e) { + log.error("Could not create cahoots wallet", e); + } + + return null; + } + + public SorobanCahootsService getSorobanCahootsService(CahootsWallet cahootsWallet) { + IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST); + RpcClient rpcClient = new RpcClient(httpClient, httpClientService.getTorProxy() != null, sorobanServer.getParams()); + return new SorobanCahootsService(bip47Util, PROVIDER_JAVA, cahootsWallet, rpcClient); + } + + public HostAndPort getTorProxy() { + return httpClientService.getTorProxy(); + } + + public void setTorProxy(HostAndPort torProxy) { + //Ensure all http clients are shutdown first + httpClientService.shutdown(); + httpClientService.setTorProxy(torProxy); + } + + public void shutdown() { + httpClientService.shutdown(); + } + + public static class ShutdownService extends Service { + private final Soroban soroban; + + public ShutdownService(Soroban soroban) { + this.soroban = soroban; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected Boolean call() throws Exception { + soroban.shutdown(); + return true; + } + }; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java b/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java new file mode 100644 index 00000000..26f3a400 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanController.java @@ -0,0 +1,122 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.samourai.wallet.cahoots.stonewallx2.STONEWALLx2; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionInput; +import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.psbt.PSBTParseException; +import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.sparrow.control.TransactionDiagram; +import com.sparrowwallet.sparrow.net.ElectrumServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +public class SorobanController { + private static final Logger log = LoggerFactory.getLogger(SorobanController.class); + + protected Transaction getTransaction(STONEWALLx2 stonewallx2) throws PSBTParseException { + if(stonewallx2.getPSBT() != null) { + PSBT psbt = new PSBT(stonewallx2.getPSBT().toBytes()); + return psbt.getTransaction(); + } + + return null; + } + + protected void updateTransactionDiagram(TransactionDiagram transactionDiagram, Wallet wallet, WalletTransaction walletTransaction, Transaction transaction) { + WalletTransaction txWalletTransaction = getWalletTransaction(wallet, walletTransaction, transaction, null); + transactionDiagram.update(txWalletTransaction); + + if(txWalletTransaction.getSelectedUtxoSets().size() == 2) { + Set references = txWalletTransaction.getSelectedUtxoSets().get(1).keySet().stream().map(BlockTransactionHash::getHash).collect(Collectors.toSet()); + ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(references); + transactionReferenceService.setOnSucceeded(successEvent -> { + Map transactionMap = transactionReferenceService.getValue(); + transactionDiagram.update(getWalletTransaction(wallet, walletTransaction, transaction, transactionMap)); + }); + transactionReferenceService.setOnFailed(failedEvent -> { + log.error("Failed to retrieve referenced transactions", failedEvent.getSource().getException()); + }); + transactionReferenceService.start(); + } + } + + private WalletTransaction getWalletTransaction(Wallet wallet, WalletTransaction walletTransaction, Transaction transaction, Map inputTransactions) { + Map allWalletUtxos = wallet.getWalletTxos(); + Map walletUtxos = new LinkedHashMap<>(); + Map externalUtxos = new LinkedHashMap<>(); + + for(TransactionInput txInput : transaction.getInputs()) { + Optional optWalletUtxo = allWalletUtxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst(); + if(optWalletUtxo.isPresent()) { + walletUtxos.put(optWalletUtxo.get(), allWalletUtxos.get(optWalletUtxo.get())); + } else { + BlockTransactionHashIndex externalUtxo; + if(inputTransactions != null && inputTransactions.containsKey(txInput.getOutpoint().getHash())) { + BlockTransaction blockTransaction = inputTransactions.get(txInput.getOutpoint().getHash()); + TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); + externalUtxo = new BlockTransactionHashIndex(blockTransaction.getHash(), blockTransaction.getHeight(), blockTransaction.getDate(), blockTransaction.getFee(), txInput.getOutpoint().getIndex(), txOutput.getValue()); + } else { + externalUtxo = new BlockTransactionHashIndex(txInput.getOutpoint().getHash(), 0, null, null, txInput.getOutpoint().getIndex(), 0); + } + externalUtxos.put(externalUtxo, null); + } + } + + List> selectedUtxoSets = new ArrayList<>(); + selectedUtxoSets.add(walletUtxos); + selectedUtxoSets.add(externalUtxos); + + Map walletAddresses = wallet.getWalletAddresses(); + List payments = new ArrayList<>(); + Map changeMap = new LinkedHashMap<>(); + for(TransactionOutput txOutput : transaction.getOutputs()) { + Address address = txOutput.getScript().getToAddress(); + if(address != null) { + Optional optPayment = walletTransaction == null ? Optional.empty() : + walletTransaction.getPayments().stream().filter(payment -> payment.getAddress().equals(address) && payment.getAmount() == txOutput.getValue()).findFirst(); + if(optPayment.isPresent()) { + payments.add(optPayment.get()); + } else if(walletAddresses.containsKey(address) && walletAddresses.get(address).getKeyPurpose() == KeyPurpose.CHANGE) { + changeMap.put(walletAddresses.get(address), txOutput.getValue()); + } else { + Payment payment = new Payment(address, null, txOutput.getValue(), false); + if(transaction.getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) { + payment.setType(Payment.Type.MIX); + } + payments.add(payment); + } + } + } + + long fee = calculateFee(walletTransaction, selectedUtxoSets, transaction); + return new WalletTransaction(wallet, transaction, Collections.emptyList(), selectedUtxoSets, payments, changeMap, fee, inputTransactions); + } + + private long calculateFee(WalletTransaction walletTransaction, List> selectedUtxoSets, Transaction transaction) { + Map selectedUtxos = new LinkedHashMap<>(); + selectedUtxoSets.forEach(selectedUtxos::putAll); + + long feeAmt = 0L; + for(BlockTransactionHashIndex utxo : selectedUtxos.keySet()) { + if(utxo.getValue() == 0) { + return walletTransaction == null ? -1 : walletTransaction.getFee(); + } + + feeAmt += utxo.getValue(); + } + + for(TransactionOutput txOutput : transaction.getOutputs()) { + feeAmt -= txOutput.getValue(); + } + + return feeAmt; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanServices.java b/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanServices.java new file mode 100644 index 00000000..c17d99e4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/SorobanServices.java @@ -0,0 +1,82 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.google.common.eventbus.Subscribe; +import com.google.common.net.HostAndPort; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.DeterministicSeed; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.WalletTabData; +import com.sparrowwallet.sparrow.event.WalletTabsClosedEvent; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.net.TorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +public class SorobanServices { + private static final Logger log = LoggerFactory.getLogger(SorobanServices.class); + + private final Map sorobanMap = new HashMap<>(); + + public Soroban getSoroban(Wallet wallet) { + Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); + for(Map.Entry entry : AppServices.get().getOpenWallets().entrySet()) { + if(entry.getKey() == masterWallet) { + return sorobanMap.get(entry.getValue().getWalletId(entry.getKey())); + } + } + + return null; + } + + public Soroban getSoroban(String walletId) { + Soroban soroban = sorobanMap.get(walletId); + if(soroban == null) { + HostAndPort torProxy = getTorProxy(); + soroban = new Soroban(Network.get(), torProxy); + sorobanMap.put(walletId, soroban); + } else { + HostAndPort torProxy = getTorProxy(); + if(!Objects.equals(soroban.getTorProxy(), torProxy)) { + soroban.setTorProxy(getTorProxy()); + } + } + + return soroban; + } + + private HostAndPort getTorProxy() { + return AppServices.isTorRunning() ? + HostAndPort.fromParts("localhost", TorService.PROXY_PORT) : + (Config.get().getProxyServer() == null || Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer())); + } + + public static boolean canWalletMix(Wallet wallet) { + return Soroban.SOROBAN_NETWORKS.contains(Network.get()) + && wallet.getKeystores().size() == 1 + && wallet.getKeystores().get(0).hasSeed() + && wallet.getKeystores().get(0).getSeed().getType() == DeterministicSeed.Type.BIP39 + && wallet.getScriptType() == ScriptType.P2WPKH; + } + + @Subscribe + public void walletTabsClosed(WalletTabsClosedEvent event) { + for(WalletTabData walletTabData : event.getClosedWalletTabData()) { + String walletId = walletTabData.getStorage().getWalletId(walletTabData.getWallet()); + Soroban soroban = sorobanMap.remove(walletId); + if(soroban != null) { + Soroban.ShutdownService shutdownService = new Soroban.ShutdownService(soroban); + shutdownService.setOnFailed(failedEvent -> { + log.error("Failed to shutdown soroban", failedEvent.getSource().getException()); + }); + shutdownService.start(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/soroban/SparrowCahootsWallet.java b/src/main/java/com/sparrowwallet/sparrow/soroban/SparrowCahootsWallet.java new file mode 100644 index 00000000..1a1fde11 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/soroban/SparrowCahootsWallet.java @@ -0,0 +1,56 @@ +package com.sparrowwallet.sparrow.soroban; + +import com.samourai.soroban.client.SorobanServer; +import com.samourai.wallet.api.backend.beans.UnspentOutput; +import com.samourai.wallet.cahoots.CahootsUtxo; +import com.samourai.wallet.cahoots.SimpleCahootsWallet; +import com.samourai.wallet.hd.HD_Address; +import com.samourai.wallet.hd.HD_Wallet; +import com.samourai.wallet.send.MyTransactionOutPoint; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.StandardAccount; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; +import org.apache.commons.lang3.tuple.Pair; + +public class SparrowCahootsWallet extends SimpleCahootsWallet { + private final Wallet wallet; + private final int account; + + public SparrowCahootsWallet(Wallet wallet, HD_Wallet bip84w, SorobanServer sorobanServer, long feePerB) throws Exception { + super(bip84w, sorobanServer.getParams(), wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), feePerB); + this.wallet = wallet; + this.account = wallet.getAccountIndex(); + bip84w.getAccount(account).getReceive().setAddrIdx(wallet.getFreshNode(KeyPurpose.RECEIVE).getIndex()); + bip84w.getAccount(account).getChange().setAddrIdx(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex()); + } + + public void addUtxo(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) { + UnspentOutput unspentOutput = Whirlpool.getUnspentOutput(wallet, node, blockTransaction, index); + MyTransactionOutPoint myTransactionOutPoint = unspentOutput.computeOutpoint(getParams()); + HD_Address hdAddress = getBip84Wallet().getAddressAt(account, unspentOutput); + CahootsUtxo cahootsUtxo = new CahootsUtxo(myTransactionOutPoint, node.getDerivationPath(), hdAddress.getECKey()); + addUtxo(account, cahootsUtxo); + } + + public int getAccount() { + return account; + } + + @Override + public Pair fetchReceiveIndex(int account) throws Exception { + if(account == StandardAccount.WHIRLPOOL_POSTMIX.getAccountNumber()) { + // force change chain + return Pair.of(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), 1); + } + + return Pair.of(wallet.getFreshNode(KeyPurpose.RECEIVE).getIndex(), 0); + } + + @Override + public Pair fetchChangeIndex(int account) throws Exception { + return Pair.of(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex(), 1); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java index 7003b951..59a79a48 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java @@ -513,10 +513,10 @@ public class HeadersController extends TransactionFormController implements Init WalletNode changeNode = changeOutputScripts.get(txOutput.getScript()); if(changeNode != null) { if(headersForm.getTransaction().getOutputs().size() == 4 && headersForm.getTransaction().getOutputs().stream().anyMatch(txo -> txo != txOutput && txo.getValue() == txOutput.getValue())) { - try { - payments.add(new Payment(txOutput.getScript().getToAddresses()[0], ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX)); - } catch(Exception e) { - //ignore + if(selectedTxos.values().stream().allMatch(Objects::nonNull)) { + payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Fake Mix)", txOutput.getValue(), false, Payment.Type.FAKE_MIX)); + } else { + payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX)); } } else { changeMap.put(changeNode, txOutput.getValue()); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java index e71ade76..4a15584f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/InputController.java @@ -149,8 +149,8 @@ public class InputController extends TransactionFormController implements Initia inputFieldset.setText(baseText + " from " + signingWallet.getFullDisplayName()); inputFieldset.setIcon(TransactionDiagram.getTxoGlyph()); } else { - inputFieldset.setText(baseText + " - Payjoin"); - inputFieldset.setIcon(TransactionDiagram.getPayjoinGlyph()); + inputFieldset.setText(baseText + " - External"); + inputFieldset.setIcon(TransactionDiagram.getMixGlyph()); } } else { inputFieldset.setText(baseText); diff --git a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java index 48207c84..46beb7b1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java +++ b/src/main/java/com/sparrowwallet/sparrow/transaction/TransactionController.java @@ -152,7 +152,7 @@ public class TransactionController implements Initializable { if(inputForm.isWalletTxo()) { setGraphic(TransactionDiagram.getTxoGlyph()); } else { - setGraphic(TransactionDiagram.getPayjoinGlyph()); + setGraphic(TransactionDiagram.getMixGlyph()); } } if(form instanceof OutputForm) { diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index bfb0d831..ed7d2c5b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -4,6 +4,7 @@ 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.Network; import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; @@ -18,6 +19,8 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.net.*; +import com.sparrowwallet.sparrow.soroban.InitiatorDialog; +import com.sparrowwallet.sparrow.soroban.SorobanServices; import com.sparrowwallet.sparrow.whirlpool.Whirlpool; import javafx.animation.KeyFrame; import javafx.animation.Timeline; @@ -37,6 +40,7 @@ import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; +import javafx.stage.Modality; import javafx.util.Duration; import javafx.util.StringConverter; import org.controlsfx.glyphfont.Glyph; @@ -949,14 +953,14 @@ public class SendController extends WalletFormController implements Initializabl } } - private boolean isFakeMixPossible(List payments) { - return (utxoSelectorProperty.get() == null + private boolean isMixPossible(List payments) { + return (utxoSelectorProperty.get() == null || SorobanServices.canWalletMix(walletForm.getWallet())) && payments.size() == 1 - && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())); + && (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType()); } private void updateOptimizationButtons(List payments) { - if(isFakeMixPossible(payments)) { + if(isMixPossible(payments)) { setPreferredOptimizationStrategy(); privacyToggle.setDisable(false); } else { @@ -975,7 +979,9 @@ public class SendController extends WalletFormController implements Initializabl } private void setPreferredOptimizationStrategy() { - optimizationToggleGroup.selectToggle(getPreferredOptimizationStrategy() == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle); + OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy(); + optimizationToggleGroup.selectToggle(optimizationStrategy == OptimizationStrategy.PRIVACY ? privacyToggle : efficiencyToggle); + transactionDiagram.setOptimizationStrategy(optimizationStrategy); } private void updatePrivacyAnalysis(WalletTransaction walletTransaction) { @@ -1369,6 +1375,38 @@ public class SendController extends WalletFormController implements Initializabl } } + @Subscribe + public void sorobanInitiated(SorobanInitiatedEvent event) { + if(event.getWallet().equals(getWalletForm().getWallet())) { + InitiatorDialog initiatorDialog = new InitiatorDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), walletTransactionProperty.get()); + if(Network.get() == Network.TESTNET) { + initiatorDialog.initModality(Modality.NONE); + } + Optional optTransaction = initiatorDialog.showAndWait(); + if(optTransaction.isPresent()) { + ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(optTransaction.get()); + broadcastTransactionService.setOnRunning(workerStateEvent -> { + createButton.setDisable(true); + addWalletTransactionNodes(); + }); + broadcastTransactionService.setOnSucceeded(workerStateEvent -> { + createButton.setDisable(false); + clear(null); + }); + broadcastTransactionService.setOnFailed(workerStateEvent -> { + createButton.setDisable(false); + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + AppServices.showErrorDialog("Error broadcasting mix transaction", exception.getMessage()); + }); + broadcastTransactionService.start(); + } + } + } + private class PrivacyAnalysisTooltip extends VBox { private List