From 2caee79df4d6448a3d30ae6ad6b7f68566bb9fe6 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Thu, 12 Aug 2021 17:50:13 +0200 Subject: [PATCH] initial whirlpool integration --- build.gradle | 70 ++++ .../javamodules/ExtraModuleInfoTransform.java | 6 +- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 42 +- .../sparrowwallet/sparrow/AppServices.java | 84 ++++ .../control/ServiceProgressDialog.java | 25 ++ .../sparrow/control/TransactionDiagram.java | 62 ++- .../sparrow/event/ChildWalletAddedEvent.java | 27 ++ .../sparrow/event/SpendUtxoEvent.java | 16 + .../sparrow/glyphfont/FontAwesome5.java | 2 + .../com/sparrowwallet/sparrow/io/Config.java | 12 +- .../sparrow/io/JsonPersistence.java | 19 +- .../sparrowwallet/sparrow/io/Persistence.java | 1 + .../com/sparrowwallet/sparrow/io/Sparrow.java | 4 +- .../com/sparrowwallet/sparrow/io/Storage.java | 4 + .../sparrow/io/WalletBackupAndKey.java | 17 +- .../sparrow/io/db/DbPersistence.java | 23 +- .../sparrow/net/ElectrumServer.java | 51 +-- .../sparrow/net/IpAddressMatcher.java | 109 ++++++ .../sparrow/wallet/PaymentController.java | 23 +- .../sparrow/wallet/SendController.java | 110 +++++- .../sparrow/wallet/UtxosController.java | 115 +++++- .../sparrow/whirlpool/SparrowBackendApi.java | 277 ++++++++++++++ .../whirlpool/SparrowMinerFeeSupplier.java | 11 + .../sparrow/whirlpool/Whirlpool.java | 361 ++++++++++++++++++ .../whirlpool/WhirlpoolController.java | 253 ++++++++++++ .../sparrow/whirlpool/WhirlpoolDialog.java | 81 ++++ .../sparrow/whirlpool/WhirlpoolException.java | 11 + src/main/java/module-info.java | 1 + .../com/sparrowwallet/sparrow/app.css | 2 +- .../sparrowwallet/sparrow/wallet/payment.fxml | 2 +- .../sparrowwallet/sparrow/wallet/send.fxml | 5 + .../sparrowwallet/sparrow/wallet/utxos.fxml | 5 + .../sparrow/whirlpool/whirlpool.css | 42 ++ .../sparrow/whirlpool/whirlpool.fxml | 99 +++++ src/main/resources/image/whirlpool.png | Bin 0 -> 1008 bytes src/main/resources/image/whirlpool@2x.png | Bin 0 -> 1812 bytes src/main/resources/image/whirlpool@3x.png | Bin 0 -> 3777 bytes src/main/resources/logback.xml | 17 + 39 files changed, 1905 insertions(+), 86 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/event/ChildWalletAddedEvent.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowBackendApi.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowMinerFeeSupplier.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolException.java create mode 100644 src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.css create mode 100644 src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml create mode 100644 src/main/resources/image/whirlpool.png create mode 100644 src/main/resources/image/whirlpool@2x.png create mode 100644 src/main/resources/image/whirlpool@3x.png diff --git a/build.gradle b/build.gradle index 75d8acfc..184aff00 100644 --- a/build.gradle +++ b/build.gradle @@ -91,6 +91,7 @@ dependencies { implementation('org.slf4j:jul-to-slf4j:1.7.30') { exclude group: 'org.slf4j' } + implementation('com.sparrowwallet.nightjar:nightjar:0.2.6') testImplementation('junit:junit:4.12') } @@ -313,6 +314,8 @@ extraJavaModuleInfo { exports('com.google.common.base') exports('com.google.common.collect') exports('com.google.common.io') + exports('com.google.common.primitives') + exports('com.google.common.math') requires('failureaccess') requires('java.logging') } @@ -384,6 +387,73 @@ extraJavaModuleInfo { module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') { exports('co.nstant.in.cbor') } + module('nightjar-0.2.6.jar', 'com.sparrowwallet.nightjar', '0.2.6') { + requires('com.google.common') + requires('net.sourceforge.streamsupport') + requires('org.slf4j') + requires('org.bouncycastle.provider') + requires('com.fasterxml.jackson.databind') + requires('logback.classic') + requires('org.json') + exports('com.sparrowwallet.nightjar') + exports('com.samourai.http.client') + exports('com.samourai.tor.client') + exports('com.samourai.wallet.api.backend') + exports('com.samourai.wallet.api.backend.beans') + exports('com.samourai.wallet.hd') + exports('com.samourai.wallet.hd.java') + exports('com.samourai.whirlpool.client.event') + exports('com.samourai.whirlpool.client.wallet') + exports('com.samourai.whirlpool.client.wallet.beans') + exports('com.samourai.whirlpool.client.whirlpool') + exports('com.samourai.whirlpool.client.whirlpool.beans') + exports('com.samourai.whirlpool.client.wallet.data.pool') + exports('com.samourai.whirlpool.client.wallet.data.utxo') + exports('com.samourai.whirlpool.client.mix.listener') + exports('com.samourai.whirlpool.protocol.beans') + exports('com.samourai.whirlpool.protocol.rest') + exports('com.samourai.whirlpool.client.tx0') + exports('com.samourai.wallet.segwit.bech32') + exports('com.samourai.whirlpool.client.wallet.data.minerFee') + exports('com.samourai.whirlpool.client.wallet.data.walletState') + exports('com.sparrowwallet.nightjar.http') + exports('com.sparrowwallet.nightjar.stomp') + exports('com.sparrowwallet.nightjar.tor') + } + module('throwing-supplier-1.0.3.jar', 'zeroleak.throwingsupplier', '1.0.3') { + exports('com.zeroleak.throwingsupplier') + } + module('okhttp-2.7.5.jar', 'com.squareup.okhttp', '2.7.5') { + exports('com.squareup.okhttp') + } + module('okio-1.6.0.jar', 'com.squareup.okio', '1.6.0') { + exports('okio') + } + module('java-jwt-3.8.1.jar', 'com.auth0.jwt', '3.8.1') { + exports('com.auth0.jwt') + } + module('json-20180130.jar', 'org.json', '1.0') { + exports('org.json') + } + module('scrypt-1.4.0.jar', 'com.lambdaworks.scrypt', '1.4.0') { + exports('com.lambdaworks.codec') + exports('com.lambdaworks.crypto') + } + module('streamsupport-1.7.0.jar', 'net.sourceforge.streamsupport', '1.7.0') { + requires('jdk.unsupported') + exports('java8.util') + exports('java8.util.function') + exports('java8.util.stream') + } + module('protobuf-java-2.6.1.jar', 'com.google.protobuf', '2.6.1') { + exports('com.google.protobuf') + } + module('commons-text-1.2.jar', 'org.apache.commons.text', '1.2') { + exports('org.apache.commons.text') + } + module('jcip-annotations-1.0.jar', 'net.jcip.annotations', '1.0') { + exports('net.jcip.annotations') + } module("netlayer-jpms-${osName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') { exports('org.berndpruenster.netlayer.tor') requires('com.github.ravn.jsocks') diff --git a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java index 94e6922b..4621956c 100644 --- a/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java +++ b/buildSrc/src/main/java/org/gradle/sample/transform/javamodules/ExtraModuleInfoTransform.java @@ -122,7 +122,11 @@ abstract public class ExtraModuleInfoTransform implements TransformAction entry : walletBackupAndKey.getChildWallets().entrySet()) { - openWallet(entry.getKey(), entry.getValue(), walletAppController, true); + for(Map.Entry entry : walletBackupAndKey.getChildWallets().entrySet()) { + openWallet(entry.getValue(), entry.getKey(), walletAppController, true); } Platform.runLater(() -> selectTab(walletBackupAndKey.getWallet())); } catch(Exception e) { @@ -842,7 +843,7 @@ public class AppController implements Initializable { } } - private void restorePublicKeysFromSeed(Wallet wallet, Key key) throws MnemonicException { + private void restorePublicKeysFromSeed(Storage storage, Wallet wallet, Key key) throws MnemonicException { if(wallet.containsPrivateKeys()) { //Derive xpub and master fingerprint from seed, potentially with passphrase Wallet copy = wallet.copy(); @@ -870,6 +871,12 @@ public class AppController implements Initializable { copy.decrypt(key); } + if(wallet.isWhirlpoolMasterWallet()) { + String walletId = storage.getWalletId(wallet); + Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId); + whirlpool.setHDWallet(copy); + } + for(int i = 0; i < wallet.getKeystores().size(); i++) { Keystore keystore = wallet.getKeystores().get(i); if(keystore.hasSeed()) { @@ -985,13 +992,13 @@ public class AppController implements Initializable { storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY); storage.saveWallet(wallet); checkWalletNetwork(wallet); - restorePublicKeysFromSeed(wallet, null); + restorePublicKeysFromSeed(storage, wallet, null); addWalletTabOrWindow(storage, wallet, null, false); for(Wallet childWallet : wallet.getChildWallets()) { storage.saveWallet(childWallet); checkWalletNetwork(childWallet); - restorePublicKeysFromSeed(childWallet, null); + restorePublicKeysFromSeed(storage, childWallet, null); addWalletTabOrWindow(storage, childWallet, null, false); } Platform.runLater(() -> selectTab(wallet)); @@ -1012,14 +1019,14 @@ public class AppController implements Initializable { storage.setEncryptionPubKey(encryptionPubKey); storage.saveWallet(wallet); checkWalletNetwork(wallet); - restorePublicKeysFromSeed(wallet, key); + restorePublicKeysFromSeed(storage, wallet, key); addWalletTabOrWindow(storage, wallet, null, false); for(Wallet childWallet : wallet.getChildWallets()) { childWallet.encrypt(key); storage.saveWallet(childWallet); checkWalletNetwork(childWallet); - restorePublicKeysFromSeed(childWallet, key); + restorePublicKeysFromSeed(storage, childWallet, key); addWalletTabOrWindow(storage, childWallet, null, false); } Platform.runLater(() -> selectTab(wallet)); @@ -1184,8 +1191,8 @@ public class AppController implements Initializable { WalletTabData walletTabData = (WalletTabData)tabData; if(walletTabData.getWallet() == wallet.getMasterWallet()) { TabPane subTabs = (TabPane)walletTab.getContent(); - subTabs.getStyleClass().remove("master-only"); addWalletSubTab(subTabs, storage, wallet, backupWallet); + Platform.runLater(() -> subTabs.getStyleClass().remove("master-only")); } } } @@ -1194,7 +1201,7 @@ public class AppController implements Initializable { public WalletForm addWalletSubTab(TabPane subTabs, Storage storage, Wallet wallet, Wallet backupWallet) { try { - Tab subTab = new Tab(wallet.getName()); + Tab subTab = new Tab(wallet.isMasterWallet() ? getAutomaticName(wallet) : wallet.getName()); subTab.setClosable(false); FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml")); subTab.setContent(walletLoader.load()); @@ -1219,6 +1226,11 @@ public class AppController implements Initializable { } } + private String getAutomaticName(Wallet wallet) { + int account = wallet.getAccountIndex(); + return account < 0 ? wallet.getName() : "Account #" + account; + } + public WalletForm getSelectedWalletForm() { Tab selectedTab = tabs.getSelectionModel().getSelectedItem(); TabData tabData = (TabData)selectedTab.getUserData(); @@ -2016,4 +2028,14 @@ public class AppController implements Initializable { public void recieveAction(ReceiveActionEvent event) { selectTab(event.getWallet()); } + + @Subscribe + public void childWalletAdded(ChildWalletAddedEvent event) { + Storage storage = AppServices.get().getOpenWallets().get(event.getWallet()); + if(storage == null) { + throw new IllegalStateException("Cannot find storage for master wallet"); + } + + addWalletTab(storage, event.getChildWallet(), null); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index f47d0450..0f2bbee5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -2,8 +2,10 @@ package com.sparrowwallet.sparrow; import com.google.common.eventbus.Subscribe; import com.google.common.net.HostAndPort; +import com.samourai.whirlpool.client.wallet.WhirlpoolEventService; import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.protocol.BlockHeader; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; @@ -15,6 +17,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.whirlpool.Whirlpool; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; @@ -89,8 +92,12 @@ public class AppServices { private TorService torService; + private final Map whirlpoolMap = new HashMap<>(); + private static Integer currentBlockHeight; + private static BlockHeader latestBlockHeader; + private static Map targetBlockFeeRates; private static final Map> mempoolHistogram = new TreeMap<>(); @@ -445,6 +452,37 @@ public class AppServices { return application; } + public Whirlpool getWhirlpool(String walletId) { + Whirlpool whirlpool = whirlpoolMap.get(walletId); + if(whirlpool == null) { + HostAndPort torProxy = AppServices.isTorRunning() ? HostAndPort.fromParts("localhost", TorService.PROXY_PORT) : (Config.get().getProxyServer().isEmpty() || !Config.get().isUseProxy() ? null : HostAndPort.fromString(Config.get().getProxyServer())); + whirlpool = new Whirlpool(Network.get(), torProxy, Config.get().getScode(), 1, 15); + whirlpoolMap.put(walletId, whirlpool); + } + + return whirlpool; + } + + private void startAllWhirlpool() { + for(Whirlpool whirlpool : whirlpoolMap.values().stream().filter(whirlpool -> whirlpool.hasWallet() && !whirlpool.isStarted()).collect(Collectors.toList())) { + Whirlpool.StartupService startupService = new Whirlpool.StartupService(whirlpool); + startupService.setOnFailed(workerStateEvent -> { + log.error("Failed to start whirlpool", workerStateEvent.getSource().getException()); + }); + startupService.start(); + } + } + + private void stopAllWhirlpool() { + for(Whirlpool whirlpool : whirlpoolMap.values().stream().filter(Whirlpool::isStarted).collect(Collectors.toList())) { + Whirlpool.ShutdownService shutdownService = new Whirlpool.ShutdownService(whirlpool); + shutdownService.setOnFailed(workerStateEvent -> { + log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException()); + }); + shutdownService.start(); + } + } + public void minimizeStage(Stage stage) { if(trayManager == null) { trayManager = new TrayManager(); @@ -503,6 +541,10 @@ public class AppServices { return currentBlockHeight; } + public static BlockHeader getLatestBlockHeader() { + return latestBlockHeader; + } + public static Map getTargetBlockFeeRates() { return targetBlockFeeRates; } @@ -776,6 +818,13 @@ public class AppServices { targetBlockFeeRates = event.getTargetBlockFeeRates(); addMempoolRateSizes(event.getMempoolRateSizes()); minimumRelayFeeRate = event.getMinimumRelayFeeRate(); + latestBlockHeader = event.getBlockHeader(); + startAllWhirlpool(); + } + + @Subscribe + public void disconnection(DisconnectionEvent event) { + stopAllWhirlpool(); } @Subscribe @@ -786,6 +835,7 @@ public class AppServices { @Subscribe public void newBlock(NewBlockEvent event) { currentBlockHeight = event.getHeight(); + latestBlockHeader = event.getBlockHeader(); String status = "Updating to new block height " + event.getHeight(); EventManager.get().post(new StatusEvent(status)); } @@ -901,6 +951,40 @@ public class AppServices { @Subscribe public void walletOpening(WalletOpeningEvent event) { restartBwt(event.getWallet()); + + String walletId = event.getStorage().getWalletId(event.getWallet()); + Whirlpool whirlpool = whirlpoolMap.get(walletId); + if(whirlpool != null && !whirlpool.isStarted() && isConnected()) { + Whirlpool.StartupService startupService = new Whirlpool.StartupService(whirlpool); + startupService.setOnFailed(workerStateEvent -> { + log.error("Failed to start whirlpool", workerStateEvent.getSource().getException()); + }); + startupService.start(); + } + } + + @Subscribe + public void walletTabsClosed(WalletTabsClosedEvent event) { + for(WalletTabData walletTabData : event.getClosedWalletTabData()) { + String walletId = walletTabData.getStorage().getWalletId(walletTabData.getWallet()); + Whirlpool whirlpool = whirlpoolMap.remove(walletId); + if(whirlpool != null) { + if(whirlpool.isStarted()) { + Whirlpool.ShutdownService shutdownService = new Whirlpool.ShutdownService(whirlpool); + shutdownService.setOnSucceeded(workerStateEvent -> { + WhirlpoolEventService.getInstance().unregister(whirlpool); + }); + shutdownService.setOnFailed(workerStateEvent -> { + log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException()); + }); + shutdownService.start(); + } else { + //Ensure http clients are shutdown + whirlpool.shutdown(); + WhirlpoolEventService.getInstance().unregister(whirlpool); + } + } + } } private void restartBwt(Wallet wallet) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java new file mode 100644 index 00000000..2e05d306 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java @@ -0,0 +1,25 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.sparrow.AppServices; +import javafx.concurrent.Worker; +import javafx.scene.control.DialogPane; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import org.controlsfx.dialog.ProgressDialog; + +public class ServiceProgressDialog extends ProgressDialog { + public ServiceProgressDialog(String title, String header, String imagePath, Worker worker) { + super(worker); + + final DialogPane dialogPane = getDialogPane(); + dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + + setTitle(title); + setHeaderText(header); + + dialogPane.getStyleClass().remove("progress-dialog"); + Image image = new Image(imagePath); + dialogPane.setGraphic(new ImageView(image)); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java index c75de9ec..08dc6da5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -377,12 +377,13 @@ public class TransactionDiagram extends GridPane { outputsBox.getChildren().add(createSpacer()); for(Payment payment : displayedPayments) { - boolean isConsolidation = walletTx.isConsolidationSend(payment); - String recipientDesc = payment instanceof AdditionalPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "..."; - Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph()); + Glyph outputGlyph = getOutputGlyph(payment); + boolean labelledPayment = outputGlyph.getStyleClass().stream().anyMatch(style -> List.of("premix-icon", "badbank-icon", "whirlpoolfee-icon").contains(style)) || payment instanceof AdditionalPayment; + String recipientDesc = labelledPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "..."; + Label recipientLabel = new Label(recipientDesc, outputGlyph); recipientLabel.getStyleClass().add("output-label"); - recipientLabel.getStyleClass().add(payment instanceof AdditionalPayment ? "additional-label" : "recipient-label"); - Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString())); + recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label"); + Tooltip recipientTooltip = new Tooltip((walletTx.isConsolidationSend(payment) ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString())); recipientTooltip.getStyleClass().add("recipient-label"); recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY)); recipientLabel.setTooltip(recipientTooltip); @@ -467,6 +468,22 @@ public class TransactionDiagram extends GridPane { return spacer; } + public Glyph getOutputGlyph(Payment payment) { + if(walletTx.isConsolidationSend(payment)) { + return getConsolidationGlyph(); + } else if(walletTx.isPremixSend(payment)) { + return getPremixGlyph(); + } else if(walletTx.isBadbankSend(payment)) { + return getBadbankGlyph(); + } else if(payment.getType().equals(Payment.Type.WHIRLPOOL_FEE)) { + return getWhirlpoolFeeGlyph(); + } else if(payment instanceof AdditionalPayment) { + return ((AdditionalPayment)payment).getOutputGlyph(this); + } + + return getPaymentGlyph(); + } + public static Glyph getExcludeGlyph() { Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE); excludeGlyph.getStyleClass().add("exclude-utxo"); @@ -488,6 +505,27 @@ public class TransactionDiagram extends GridPane { return consolidationGlyph; } + public static Glyph getPremixGlyph() { + Glyph premixGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM); + premixGlyph.getStyleClass().add("premix-icon"); + premixGlyph.setFontSize(12); + return premixGlyph; + } + + public static Glyph getBadbankGlyph() { + Glyph badbankGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BIOHAZARD); + badbankGlyph.getStyleClass().add("badbank-icon"); + badbankGlyph.setFontSize(12); + return badbankGlyph; + } + + public static Glyph getWhirlpoolFeeGlyph() { + Glyph whirlpoolFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_WATER); + whirlpoolFeeGlyph.getStyleClass().add("whirlpoolfee-icon"); + whirlpoolFeeGlyph.setFontSize(12); + return whirlpoolFeeGlyph; + } + public static Glyph getTxoGlyph() { return getChangeGlyph(); } @@ -578,6 +616,20 @@ public class TransactionDiagram extends GridPane { this.additionalPayments = additionalPayments; } + public Glyph getOutputGlyph(TransactionDiagram transactionDiagram) { + Glyph glyph = null; + for(Payment payment : additionalPayments) { + Glyph paymentGlyph = transactionDiagram.getOutputGlyph(payment); + if(glyph != null && !paymentGlyph.getStyleClass().equals(glyph.getStyleClass())) { + return getPaymentGlyph(); + } + + glyph = paymentGlyph; + } + + return glyph; + } + public String toString() { return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n")); } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/ChildWalletAddedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/ChildWalletAddedEvent.java new file mode 100644 index 00000000..1ac2351c --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/ChildWalletAddedEvent.java @@ -0,0 +1,27 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.io.Storage; + +public class ChildWalletAddedEvent extends WalletChangedEvent { + private final Storage storage; + private final Wallet childWallet; + + public ChildWalletAddedEvent(Storage storage, Wallet masterWallet, Wallet childWallet) { + super(masterWallet); + this.storage = storage; + this.childWallet = childWallet; + } + + public Storage getStorage() { + return storage; + } + + public Wallet getChildWallet() { + return childWallet; + } + + public String getMasterWalletId() { + return storage.getWalletId(getWallet()); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java index 0ae04620..4cfc2a0d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.event; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; import com.sparrowwallet.drongo.wallet.Payment; import com.sparrowwallet.drongo.wallet.Wallet; @@ -12,6 +13,7 @@ public class SpendUtxoEvent { private final List payments; private final Long fee; private final boolean includeSpentMempoolOutputs; + private final Pool pool; public SpendUtxoEvent(Wallet wallet, List utxos) { this(wallet, utxos, null, null, false); @@ -23,6 +25,16 @@ public class SpendUtxoEvent { this.payments = payments; this.fee = fee; this.includeSpentMempoolOutputs = includeSpentMempoolOutputs; + this.pool = null; + } + + public SpendUtxoEvent(Wallet wallet, List utxos, List payments, Long fee, Pool pool) { + this.wallet = wallet; + this.utxos = utxos; + this.payments = payments; + this.fee = fee; + this.includeSpentMempoolOutputs = false; + this.pool = pool; } public Wallet getWallet() { @@ -44,4 +56,8 @@ public class SpendUtxoEvent { public boolean isIncludeSpentMempoolOutputs() { return includeSpentMempoolOutputs; } + + public Pool getPool() { + return pool; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index 5dc5a873..86184e52 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -20,6 +20,7 @@ public class FontAwesome5 extends GlyphFont { ARROW_DOWN('\uf063'), ARROW_UP('\uf062'), BAN('\uf05e'), + BIOHAZARD('\uf780'), BTC('\uf15a'), CAMERA('\uf030'), CHECK_CIRCLE('\uf058'), @@ -35,6 +36,7 @@ public class FontAwesome5 extends GlyphFont { FILE_CSV('\uf6dd'), HAND_HOLDING('\uf4bd'), HAND_HOLDING_MEDICAL('\ue05c'), + HAND_HOLDING_WATER('\uf4c1'), HISTORY('\uf1da'), KEY('\uf084'), LAPTOP('\uf109'), diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Config.java b/src/main/java/com/sparrowwallet/sparrow/io/Config.java index 42e74f21..41a841de 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Config.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Config.java @@ -11,8 +11,6 @@ import org.slf4j.LoggerFactory; import java.io.*; import java.lang.reflect.Type; -import java.nio.file.Files; -import java.nio.file.attribute.PosixFilePermissions; import java.util.Arrays; import java.util.Currency; import java.util.List; @@ -59,6 +57,7 @@ public class Config { private File electrumServerCert; private boolean useProxy; private String proxyServer; + private String scode; private static Config INSTANCE; @@ -459,6 +458,15 @@ public class Config { flush(); } + public String getScode() { + return scode; + } + + public void setScode(String scode) { + this.scode = scode; + flush(); + } + private synchronized void flush() { Gson gson = getGson(); try { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java index 3e2cabbb..8b47d55d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java @@ -43,8 +43,8 @@ public class JsonPersistence implements Persistence { wallet = gson.fromJson(reader, Wallet.class); } - Map childWallets = loadChildWallets(storage, wallet, null); - wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList())); + Map childWallets = loadChildWallets(storage, wallet, null); + wallet.setChildWallets(childWallets.keySet().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList())); File backupFile = storage.getTempBackup(); Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, null); @@ -68,8 +68,8 @@ public class JsonPersistence implements Persistence { wallet = gson.fromJson(reader, Wallet.class); } - Map childWallets = loadChildWallets(storage, wallet, encryptionKey); - wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList())); + Map childWallets = loadChildWallets(storage, wallet, encryptionKey); + wallet.setChildWallets(childWallets.keySet().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList())); File backupFile = storage.getTempBackup(); Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, encryptionKey); @@ -77,16 +77,16 @@ public class JsonPersistence implements Persistence { return new WalletBackupAndKey(wallet, backupWallet, encryptionKey, keyDeriver, childWallets); } - private Map loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException { + private Map loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException { File[] walletFiles = getChildWalletFiles(storage.getWalletFile(), masterWallet); - Map childWallets = new LinkedHashMap<>(); + Map childWallets = new TreeMap<>(); for(File childFile : walletFiles) { Wallet childWallet = loadWallet(childFile, encryptionKey); Storage childStorage = new Storage(childFile); childStorage.setEncryptionPubKey(encryptionKey == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(encryptionKey)); childStorage.setKeyDeriver(getKeyDeriver()); childWallet.setMasterWallet(masterWallet); - childWallets.put(childStorage, new WalletBackupAndKey(childWallet, null, encryptionKey, keyDeriver, Collections.emptyMap())); + childWallets.put(new WalletBackupAndKey(childWallet, null, encryptionKey, keyDeriver, Collections.emptyMap()), storage); } return childWallets; @@ -200,6 +200,11 @@ public class JsonPersistence implements Persistence { return "BIE1".getBytes(StandardCharsets.UTF_8); } + @Override + public boolean isPersisted(Storage storage, Wallet wallet) { + return storage.getWalletFile().exists(); + } + @Override public ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException { return getEncryptionKey(password, null, null); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Persistence.java b/src/main/java/com/sparrowwallet/sparrow/io/Persistence.java index 252724a6..4b5d7fff 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Persistence.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Persistence.java @@ -16,6 +16,7 @@ public interface Persistence { File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException; void updateWallet(Storage storage, Wallet wallet) throws IOException, StorageException; void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException; + boolean isPersisted(Storage storage, Wallet wallet); ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException; AsymmetricKeyDeriver getKeyDeriver(); void setKeyDeriver(AsymmetricKeyDeriver keyDeriver); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Sparrow.java b/src/main/java/com/sparrowwallet/sparrow/io/Sparrow.java index a152ae71..ba8779fd 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Sparrow.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Sparrow.java @@ -111,8 +111,8 @@ public class Sparrow implements WalletImport, WalletExport { WalletBackupAndKey walletBackupAndKey = storage.loadEncryptedWallet(password); wallet = walletBackupAndKey.getWallet(); wallet.decrypt(walletBackupAndKey.getKey()); - for(Map.Entry entry : walletBackupAndKey.getChildWallets().entrySet()) { - entry.getValue().getWallet().decrypt(entry.getValue().getKey()); + for(Map.Entry entry : walletBackupAndKey.getChildWallets().entrySet()) { + entry.getKey().getWallet().decrypt(entry.getKey().getKey()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java index ba1b7ae2..19135404 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Storage.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Storage.java @@ -115,6 +115,10 @@ public class Storage { } } + public boolean isPersisted(Wallet wallet) { + return persistence.isPersisted(this, wallet); + } + public void close() { ClosePersistenceService closePersistenceService = new ClosePersistenceService(); closePersistenceService.start(); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/WalletBackupAndKey.java b/src/main/java/com/sparrowwallet/sparrow/io/WalletBackupAndKey.java index 755d4d01..c3e4a06c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/WalletBackupAndKey.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/WalletBackupAndKey.java @@ -5,14 +5,14 @@ import com.sparrowwallet.drongo.wallet.Wallet; import java.util.Map; -public class WalletBackupAndKey { +public class WalletBackupAndKey implements Comparable { private final Wallet wallet; private final Wallet backupWallet; private final ECKey encryptionKey; private final Key key; - private final Map childWallets; + private final Map childWallets; - public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, ECKey encryptionKey, AsymmetricKeyDeriver keyDeriver, Map childWallets) { + public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, ECKey encryptionKey, AsymmetricKeyDeriver keyDeriver, Map childWallets) { this.wallet = wallet; this.backupWallet = backupWallet; this.encryptionKey = encryptionKey; @@ -36,7 +36,7 @@ public class WalletBackupAndKey { return key; } - public Map getChildWallets() { + public Map getChildWallets() { return childWallets; } @@ -48,4 +48,13 @@ public class WalletBackupAndKey { key.clear(); } } + + @Override + public int compareTo(WalletBackupAndKey other) { + if(wallet.getStandardAccountType() != null && other.wallet.getStandardAccountType() != null) { + return wallet.getStandardAccountType().ordinal() - other.wallet.getStandardAccountType().ordinal(); + } + + return wallet.getAccountIndex() - other.wallet.getAccountIndex(); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java index e8f179fa..af772930 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java @@ -90,20 +90,20 @@ public class DbPersistence implements Persistence { backupWallet = backupPersistence.loadWallet(new Storage(backupPersistence, backupFile), password, encryptionKey).getWallet(); } - Map childWallets = loadChildWallets(storage, masterWallet, backupWallet, encryptionKey); - masterWallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList())); + Map childWallets = loadChildWallets(storage, masterWallet, backupWallet, encryptionKey); + masterWallet.setChildWallets(childWallets.keySet().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList())); return new WalletBackupAndKey(masterWallet, backupWallet, encryptionKey, keyDeriver, childWallets); } - private Map loadChildWallets(Storage storage, Wallet masterWallet, Wallet backupWallet, ECKey encryptionKey) throws StorageException { + private Map loadChildWallets(Storage storage, Wallet masterWallet, Wallet backupWallet, ECKey encryptionKey) throws StorageException { Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey)); List schemas = jdbi.withHandle(handle -> { return handle.createQuery("show schemas").mapTo(String.class).list(); }); List childSchemas = schemas.stream().filter(schema -> schema.startsWith(WALLET_SCHEMA_PREFIX) && !schema.equals(MASTER_SCHEMA)).collect(Collectors.toList()); - Map childWallets = new LinkedHashMap<>(); + Map childWallets = new TreeMap<>(); for(String schema : childSchemas) { migrate(storage, schema, encryptionKey); @@ -116,7 +116,7 @@ public class DbPersistence implements Persistence { return childWallet; }); Wallet backupChildWallet = backupWallet == null ? null : backupWallet.getChildWallets().stream().filter(child -> wallet.getName().equals(child.getName())).findFirst().orElse(null); - childWallets.put(storage, new WalletBackupAndKey(wallet, backupChildWallet, encryptionKey, keyDeriver, Collections.emptyMap())); + childWallets.put(new WalletBackupAndKey(wallet, backupChildWallet, encryptionKey, keyDeriver, Collections.emptyMap()), storage); } return childWallets; @@ -184,6 +184,14 @@ public class DbPersistence implements Persistence { log.debug(dirtyPersistables.toString()); Jdbi jdbi = getJdbi(storage, password); + List schemas = jdbi.withHandle(handle -> { + return handle.createQuery("show schemas").mapTo(String.class).list(); + }); + if(!schemas.contains(getSchema(wallet))) { + log.debug("Not persisting update for missing schema " + getSchema(wallet)); + return; + } + jdbi.useHandle(handle -> { WalletDao walletDao = handle.attach(WalletDao.class); try { @@ -399,6 +407,11 @@ public class DbPersistence implements Persistence { return null; } + @Override + public boolean isPersisted(Storage storage, Wallet wallet) { + return wallet.getId() != null; + } + @Override public ECKey getEncryptionKey(CharSequence password) throws IOException { return getEncryptionKey(password, null, null); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index a149e26d..500aa4b2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -100,8 +100,9 @@ public class ElectrumServer { previousServerAddress = electrumServer; HostAndPort server = protocol.getServerHostAndPort(electrumServer); + boolean localNetworkAddress = !protocol.isOnionAddress(server) && IpAddressMatcher.isLocalNetworkAddress(server.getHost()); - if(Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) { + if(!localNetworkAddress && Config.get().isUseProxy() && proxyServer != null && !proxyServer.isBlank()) { HostAndPort proxy = HostAndPort.fromString(proxyServer); if(electrumServerCert != null) { transport = protocol.getTransport(server, electrumServerCert, proxy); @@ -765,6 +766,31 @@ public class ElectrumServer { return Transaction.DEFAULT_MIN_RELAY_FEE; } + public Sha256Hash broadcastTransactionPrivately(Transaction transaction) throws ServerException { + //If Tor proxy is configured, try all external broadcast sources in random order before falling back to connected Electrum server + if(AppServices.isUsingProxy()) { + List broadcastSources = Arrays.stream(BroadcastSource.values()).filter(src -> src.getSupportedNetworks().contains(Network.get())).collect(Collectors.toList()); + Sha256Hash txid = null; + for(int i = 1; !broadcastSources.isEmpty(); i++) { + try { + BroadcastSource broadcastSource = broadcastSources.remove(new Random().nextInt(broadcastSources.size())); + txid = broadcastSource.broadcastTransaction(transaction); + if(Network.get() != Network.MAINNET || i >= MINIMUM_BROADCASTS || broadcastSources.isEmpty()) { + return txid; + } + } catch(BroadcastSource.BroadcastException e) { + //ignore, already logged + } + } + + if(txid != null) { + return txid; + } + } + + return broadcastTransaction(transaction); + } + public Sha256Hash broadcastTransaction(Transaction transaction) throws ServerException { byte[] rawtxBytes = transaction.bitcoinSerialize(); String rawtxHex = Utils.bytesToHex(rawtxBytes); @@ -1351,29 +1377,8 @@ public class ElectrumServer { protected Task createTask() { return new Task<>() { protected Sha256Hash call() throws ServerException { - //If Tor proxy is configured, try all external broadcast sources in random order before falling back to connected Electrum server - if(AppServices.isUsingProxy()) { - List broadcastSources = Arrays.stream(BroadcastSource.values()).filter(src -> src.getSupportedNetworks().contains(Network.get())).collect(Collectors.toList()); - Sha256Hash txid = null; - for(int i = 1; !broadcastSources.isEmpty(); i++) { - try { - BroadcastSource broadcastSource = broadcastSources.remove(new Random().nextInt(broadcastSources.size())); - txid = broadcastSource.broadcastTransaction(transaction); - if(Network.get() != Network.MAINNET || i >= MINIMUM_BROADCASTS || broadcastSources.isEmpty()) { - return txid; - } - } catch(BroadcastSource.BroadcastException e) { - //ignore, already logged - } - } - - if(txid != null) { - return txid; - } - } - ElectrumServer electrumServer = new ElectrumServer(); - return electrumServer.broadcastTransaction(transaction); + return electrumServer.broadcastTransactionPrivately(transaction); } }; } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java b/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java new file mode 100644 index 00000000..bfe0f7b9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java @@ -0,0 +1,109 @@ +package com.sparrowwallet.sparrow.net; + +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Matches a request based on IP Address or subnet mask matching against the remote + * address. + *

+ * Both IPv6 and IPv4 addresses are supported, but a matcher which is configured with an + * IPv4 address will never match a request which returns an IPv6 address, and vice-versa. + * + * @author Luke Taylor + * @since 3.0.2 + * + * Slightly modified by omidzk to have zero dependency to any frameworks other than the JRE. + */ +public final class IpAddressMatcher { + private static final IpAddressMatcher LOCAL_RANGE_1 = new IpAddressMatcher("10.0.0.0/8"); + private static final IpAddressMatcher LOCAL_RANGE_2 = new IpAddressMatcher("172.16.0.0/12"); + private static final IpAddressMatcher LOCAL_RANGE_3 = new IpAddressMatcher("192.168.0.0/16"); + + private final int nMaskBits; + private final InetAddress requiredAddress; + + /** + * Takes a specific IP address or a range specified using the IP/Netmask (e.g. + * 192.168.1.0/24 or 202.24.0.0/14). + * + * @param ipAddress the address or range of addresses from which the request must + * come. + */ + public IpAddressMatcher(String ipAddress) { + + if (ipAddress.indexOf('/') > 0) { + String[] addressAndMask = ipAddress.split("/"); + ipAddress = addressAndMask[0]; + nMaskBits = Integer.parseInt(addressAndMask[1]); + } + else { + nMaskBits = -1; + } + requiredAddress = parseAddress(ipAddress); + assert (requiredAddress.getAddress().length * 8 >= nMaskBits) : + String.format("IP address %s is too short for bitmask of length %d", + ipAddress, nMaskBits); + } + + public boolean matches(String address) { + InetAddress remoteAddress = parseAddress(address); + + if (!requiredAddress.getClass().equals(remoteAddress.getClass())) { + return false; + } + + if (nMaskBits < 0) { + return remoteAddress.equals(requiredAddress); + } + + byte[] remAddr = remoteAddress.getAddress(); + byte[] reqAddr = requiredAddress.getAddress(); + + int nMaskFullBytes = nMaskBits / 8; + byte finalByte = (byte) (0xFF00 >> (nMaskBits & 0x07)); + + // System.out.println("Mask is " + new sun.misc.HexDumpEncoder().encode(mask)); + + for (int i = 0; i < nMaskFullBytes; i++) { + if (remAddr[i] != reqAddr[i]) { + return false; + } + } + + if (finalByte != 0) { + return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); + } + + return true; + } + + private InetAddress parseAddress(String address) { + try { + return InetAddress.getByName(address); + } + catch (UnknownHostException e) { + throw new IllegalArgumentException("Failed to parse address" + address, e); + } + } + + public static boolean isLocalNetworkAddress(String address) { + return LOCAL_RANGE_1.matches(address) || LOCAL_RANGE_2.matches(address) || LOCAL_RANGE_3.matches(address); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index b89eb192..095af18f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -72,6 +72,9 @@ public class PaymentController extends WalletFormController implements Initializ @FXML private ToggleButton maxButton; + @FXML + private Button scanQrButton; + @FXML private Button addPaymentButton; @@ -287,11 +290,16 @@ public class PaymentController extends WalletFormController implements Initializ public Payment getPayment(boolean sendAll) { try { - Address address = getRecipientAddress(); + Address recipientAddress = getRecipientAddress(); Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats(); if(!label.getText().isEmpty() && value != null && value > getRecipientDustThreshold()) { - return new Payment(address, label.getText(), value, sendAll); + Payment payment = new Payment(recipientAddress, label.getText(), value, sendAll); + if(address.getUserData() != null) { + payment.setType((Payment.Type)address.getUserData()); + } + + return payment; } } catch(InvalidAddressException e) { //ignore @@ -304,6 +312,7 @@ public class PaymentController extends WalletFormController implements Initializ if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) { if(payment.getAddress() != null) { address.setText(payment.getAddress().toString()); + address.setUserData(payment.getType()); } if(payment.getLabel() != null && !label.getText().equals(payment.getLabel())) { label.setText(payment.getLabel()); @@ -406,6 +415,16 @@ public class PaymentController extends WalletFormController implements Initializ maxButton.setSelected(sendMax); } + public void setInputFieldsDisabled(boolean disable) { + address.setDisable(disable); + label.setDisable(disable); + amount.setDisable(disable); + amountUnit.setDisable(disable); + maxButton.setDisable(disable); + scanQrButton.setDisable(disable); + addPaymentButton.setDisable(disable); + } + @Subscribe public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) { BitcoinUnit unit = sendController.getBitcoinUnit(event.getBitcoinUnit()); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index 29513ab2..4442ad61 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -1,8 +1,11 @@ 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.SecureString; import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.*; @@ -13,7 +16,9 @@ import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.net.*; +import com.sparrowwallet.sparrow.whirlpool.Whirlpool; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Platform; @@ -116,6 +121,9 @@ public class SendController extends WalletFormController implements Initializabl @FXML private Button createButton; + @FXML + private Button premixButton; + private StackPane tabHeader; private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false); @@ -124,6 +132,8 @@ public class SendController extends WalletFormController implements Initializabl private final ObjectProperty utxoFilterProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty whirlpoolProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty walletTransactionProperty = new SimpleObjectProperty<>(null); private final ObjectProperty createdWalletTransactionProperty = new SimpleObjectProperty<>(null); @@ -370,6 +380,14 @@ public class SendController extends WalletFormController implements Initializabl }); addFeeRangeTrackHighlight(0); + + createButton.managedProperty().bind(createButton.visibleProperty()); + premixButton.managedProperty().bind(premixButton.visibleProperty()); + createButton.visibleProperty().bind(premixButton.visibleProperty().not()); + premixButton.setVisible(false); + AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { + premixButton.setDisable(!newValue); + }); } private void initializeTabHeader(int count) { @@ -379,7 +397,7 @@ public class SendController extends WalletFormController implements Initializabl if(stackPane != null) { tabHeader = stackPane; tabHeader.managedProperty().bind(tabHeader.visibleProperty()); - tabHeader.setVisible(false); + tabHeader.setVisible(paymentTabs.getTabs().size() > 1); paymentTabs.getStyleClass().remove("initial"); } else if(lookupCount < 20) { initializeTabHeader(lookupCount+1); @@ -908,6 +926,8 @@ public class SendController extends WalletFormController implements Initializabl insufficientInputsProperty.set(false); validationSupport.setErrorDecorationEnabled(false); + + setInputFieldsDisabled(false); } public UtxoSelector getUtxoSelector() { @@ -993,6 +1013,88 @@ public class SendController extends WalletFormController implements Initializabl walletForm.addWalletTransactionNodes(nodes); } + public void broadcastPremix(ActionEvent event) { + //Ensure all child wallets have been saved + for(Wallet childWallet : getWalletForm().getWallet().getChildWallets()) { + Storage storage = AppServices.get().getOpenWallets().get(childWallet); + if(!storage.isPersisted(childWallet)) { + try { + storage.saveWallet(childWallet); + } catch(Exception e) { + AppServices.showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage()); + } + } + } + + Wallet copy = getWalletForm().getWallet().copy(); + String walletId = walletForm.getWalletId(); + + if(copy.isEncrypted()) { + WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get()); + decryptWalletService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done")); + Wallet decryptedWallet = decryptWalletService.getValue(); + broadcastPremixUnencrypted(decryptedWallet); + }); + decryptWalletService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed")); + AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage()); + }); + EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet...")); + decryptWalletService.start(); + } + } else { + broadcastPremixUnencrypted(copy); + } + } + + public void broadcastPremixUnencrypted(Wallet decryptedWallet) { + Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWalletId()); + whirlpool.setScode(Config.get().getScode()); + whirlpool.setHDWallet(decryptedWallet); + Map utxos = walletTransactionProperty.get().getSelectedUtxos(); + Whirlpool.Tx0BroadcastService tx0BroadcastService = new Whirlpool.Tx0BroadcastService(whirlpool, whirlpoolProperty.get(), utxos.keySet()); + tx0BroadcastService.setOnRunning(workerStateEvent -> { + premixButton.setDisable(true); + addWalletTransactionNodes(); + }); + tx0BroadcastService.setOnSucceeded(workerStateEvent -> { + premixButton.setDisable(false); + Sha256Hash txid = tx0BroadcastService.getValue(); + decryptedWallet.clearPrivate(); + clear(null); + }); + tx0BroadcastService.setOnFailed(workerStateEvent -> { + premixButton.setDisable(false); + decryptedWallet.clearPrivate(); + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + AppServices.showErrorDialog("Error broadcasting premix transaction", exception.getMessage()); + }); + ServiceProgressDialog progressDialog = new ServiceProgressDialog("Whirlpool", "Broadcast Premix Transaction", "/image/whirlpool.png", tx0BroadcastService); + tx0BroadcastService.start(); + } + + private void setInputFieldsDisabled(boolean disable) { + for(int i = 0; i < paymentTabs.getTabs().size(); i++) { + Tab tab = paymentTabs.getTabs().get(i); + tab.setClosable(!disable); + PaymentController controller = (PaymentController)tab.getUserData(); + controller.setInputFieldsDisabled(disable); + + feeRange.setDisable(disable); + targetBlocks.setDisable(disable); + fee.setDisable(disable); + feeAmountUnit.setDisable(disable); + } + } + @Subscribe public void walletNodesChanged(WalletNodesChangedEvent event) { if(event.getWallet().equals(walletForm.getWallet())) { @@ -1079,7 +1181,13 @@ public class SendController extends WalletFormController implements Initializabl List utxos = event.getUtxos(); utxoSelectorProperty.set(new PresetUtxoSelector(utxos)); utxoFilterProperty.set(null); + whirlpoolProperty.set(event.getPool()); updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax)); + + boolean isWhirlpoolPremix = (event.getPayments() != null && event.getPayments().stream().anyMatch(payment -> payment.getType().equals(Payment.Type.WHIRLPOOL_FEE))); + setInputFieldsDisabled(isWhirlpoolPremix); + premixButton.setVisible(isWhirlpoolPremix); + premixButton.setDefaultButton(isWhirlpoolPremix); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java index b15c13e0..3538e6d2 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java @@ -2,17 +2,20 @@ package com.sparrowwallet.sparrow.wallet; import com.csvreader.CsvWriter; import com.google.common.eventbus.Subscribe; +import com.samourai.whirlpool.client.tx0.Tx0Preview; import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; import com.sparrowwallet.drongo.protocol.Transaction; -import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; -import com.sparrowwallet.sparrow.control.CoinLabel; -import com.sparrowwallet.sparrow.control.EntryCell; -import com.sparrowwallet.sparrow.control.UtxosChart; -import com.sparrowwallet.sparrow.control.UtxosTreeTable; +import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.whirlpool.WhirlpoolDialog; import javafx.application.Platform; import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; @@ -30,9 +33,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Locale; -import java.util.ResourceBundle; +import java.util.*; import java.util.stream.Collectors; public class UtxosController extends WalletFormController implements Initializable { @@ -44,6 +45,9 @@ public class UtxosController extends WalletFormController implements Initializab @FXML private Button sendSelected; + @FXML + private Button mixSelected; + @FXML private UtxosChart utxosChart; @@ -58,23 +62,33 @@ public class UtxosController extends WalletFormController implements Initializab utxosChart.initialize(getWalletForm().getWalletUtxosEntry()); sendSelected.setDisable(true); sendSelected.setTooltip(new Tooltip("Send selected UTXOs. Use " + (org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? "Cmd" : "Ctrl") + "+click to select multiple." )); + mixSelected.managedProperty().bind(mixSelected.visibleProperty()); + mixSelected.setVisible(canWalletMix()); + mixSelected.setDisable(true); + AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { + mixSelected.setDisable(getSelectedEntries().isEmpty() || !newValue); + }); utxosTable.getSelectionModel().getSelectedIndices().addListener((ListChangeListener) c -> { List selectedEntries = utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue()).collect(Collectors.toList()); utxosChart.select(selectedEntries); - updateSendSelected(Config.get().getBitcoinUnit()); + updateButtons(Config.get().getBitcoinUnit()); }); utxosChart.managedProperty().bind(utxosChart.visibleProperty()); utxosChart.setVisible(Config.get().isShowUtxosChart()); } - private void updateSendSelected(BitcoinUnit unit) { - List selectedEntries = utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue()) - .filter(entry -> ((HashIndexEntry)entry).isSpendable()) - .collect(Collectors.toList()); + private boolean canWalletMix() { + return Network.get() == Network.TESTNET && getWalletForm().getWallet().getKeystores().size() == 1 && getWalletForm().getWallet().getKeystores().get(0).hasSeed(); + } + + private void updateButtons(BitcoinUnit unit) { + List selectedEntries = getSelectedEntries(); sendSelected.setDisable(selectedEntries.isEmpty()); + mixSelected.setDisable(selectedEntries.isEmpty() || !AppServices.isConnected()); + long selectedTotal = selectedEntries.stream().mapToLong(Entry::getValue).sum(); if(selectedTotal > 0) { if(unit == null || unit.equals(BitcoinUnit.AUTO)) { @@ -83,27 +97,85 @@ public class UtxosController extends WalletFormController implements Initializab if(unit.equals(BitcoinUnit.BTC)) { sendSelected.setText("Send Selected (" + CoinLabel.getBTCFormat().format((double)selectedTotal / Transaction.SATOSHIS_PER_BITCOIN) + " BTC)"); + mixSelected.setText("Mix Selected (" + CoinLabel.getBTCFormat().format((double)selectedTotal / Transaction.SATOSHIS_PER_BITCOIN) + " BTC)"); } else { sendSelected.setText("Send Selected (" + String.format(Locale.ENGLISH, "%,d", selectedTotal) + " sats)"); + mixSelected.setText("Mix Selected (" + String.format(Locale.ENGLISH, "%,d", selectedTotal) + " sats)"); } } else { sendSelected.setText("Send Selected"); + sendSelected.setText("Mix Selected"); } } - public void sendSelected(ActionEvent event) { - List utxoEntries = utxosTable.getSelectionModel().getSelectedCells().stream() - .map(tp -> tp.getTreeItem().getValue()) - .filter(e -> e instanceof HashIndexEntry) - .map(e -> (HashIndexEntry)e) - .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable()) + private List getSelectedEntries() { + return utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue()) + .filter(entry -> ((HashIndexEntry)entry).isSpendable()) .collect(Collectors.toList()); + } + public void sendSelected(ActionEvent event) { + List utxoEntries = getSelectedUtxos(); final List spendingUtxos = utxoEntries.stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList()); EventManager.get().post(new SendActionEvent(getWalletForm().getWallet(), spendingUtxos)); Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(getWalletForm().getWallet(), spendingUtxos))); } + public void mixSelected(ActionEvent event) { + List selectedEntries = getSelectedUtxos(); + WhirlpoolDialog whirlpoolDialog = new WhirlpoolDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), selectedEntries); + Optional optTx0Preview = whirlpoolDialog.showAndWait(); + optTx0Preview.ifPresent(tx0Preview -> previewPremixTransaction(getWalletForm().getWallet(), tx0Preview, selectedEntries)); + } + + public void previewPremixTransaction(Wallet wallet, Tx0Preview tx0Preview, List utxoEntries) { + for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) { + if(wallet.getChildWallet(whirlpoolAccount) == null) { + Wallet childWallet = wallet.addChildWallet(whirlpoolAccount); + EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), wallet, childWallet)); + } + } + + Wallet premixWallet = wallet.getChildWallet(StandardAccount.WHIRLPOOL_PREMIX); + Wallet badbankWallet = wallet.getChildWallet(StandardAccount.WHIRLPOOL_BADBANK); + + List payments = new ArrayList<>(); + try { + Address whirlpoolFeeAddress = Address.fromString(tx0Preview.getTx0Data().getFeeAddress()); + Payment whirlpoolFeePayment = new Payment(whirlpoolFeeAddress, "Whirlpool Fee", tx0Preview.getFeeValue(), false); + whirlpoolFeePayment.setType(Payment.Type.WHIRLPOOL_FEE); + payments.add(whirlpoolFeePayment); + } catch(InvalidAddressException e) { + throw new IllegalStateException("Cannot parse whirlpool fee address " + tx0Preview.getTx0Data().getFeeAddress(), e); + } + + WalletNode badbankNode = badbankWallet.getFreshNode(KeyPurpose.RECEIVE); + Payment changePayment = new Payment(badbankWallet.getAddress(badbankNode), "Badbank Change", tx0Preview.getChangeValue(), false); + payments.add(changePayment); + + WalletNode premixNode = null; + for(int i = 0; i < tx0Preview.getNbPremix(); i++) { + premixNode = premixWallet.getFreshNode(KeyPurpose.RECEIVE, premixNode); + Address premixAddress = premixWallet.getAddress(premixNode); + payments.add(new Payment(premixAddress, "Premix #" + i, tx0Preview.getPremixValue(), false)); + } + + final List utxos = utxoEntries.stream().map(HashIndexEntry::getHashIndex).collect(Collectors.toList()); + Platform.runLater(() -> { + EventManager.get().post(new SendActionEvent(getWalletForm().getWallet(), utxos)); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(getWalletForm().getWallet(), utxos, payments, tx0Preview.getTx0MinerFee(), tx0Preview.getPool()))); + }); + } + + private List getSelectedUtxos() { + return utxosTable.getSelectionModel().getSelectedCells().stream() + .map(tp -> tp.getTreeItem().getValue()) + .filter(e -> e instanceof HashIndexEntry) + .map(e -> (UtxoEntry)e) + .filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.isSpendable()) + .collect(Collectors.toList()); + } + public void clear(ActionEvent event) { utxosTable.getSelectionModel().clearSelection(); } @@ -150,6 +222,7 @@ public class UtxosController extends WalletFormController implements Initializab WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry(); utxosTable.updateAll(walletUtxosEntry); utxosChart.update(walletUtxosEntry); + mixSelected.setVisible(canWalletMix()); } } @@ -180,7 +253,7 @@ public class UtxosController extends WalletFormController implements Initializab public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) { utxosTable.setBitcoinUnit(getWalletForm().getWallet(), event.getBitcoinUnit()); utxosChart.setBitcoinUnit(getWalletForm().getWallet(), event.getBitcoinUnit()); - updateSendSelected(event.getBitcoinUnit()); + updateButtons(event.getBitcoinUnit()); } @Subscribe @@ -213,7 +286,7 @@ public class UtxosController extends WalletFormController implements Initializab public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) { if(event.getWallet().equals(getWalletForm().getWallet())) { utxosTable.refresh(); - updateSendSelected(Config.get().getBitcoinUnit()); + updateButtons(Config.get().getBitcoinUnit()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowBackendApi.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowBackendApi.java new file mode 100644 index 00000000..6a23b79e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowBackendApi.java @@ -0,0 +1,277 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.wallet.api.backend.BackendApi; +import com.samourai.wallet.api.backend.MinerFee; +import com.samourai.wallet.api.backend.MinerFeeTarget; +import com.samourai.wallet.api.backend.beans.*; +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyPurpose; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.wallet.BlockTransaction; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.net.ElectrumServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +@SuppressWarnings("deprecation") +public class SparrowBackendApi extends BackendApi { + private static final Logger log = LoggerFactory.getLogger(SparrowBackendApi.class); + private static final int FALLBACK_FEE_RATE = 75; + + public SparrowBackendApi() { + super(null, null); + } + + @Override + public TxsResponse fetchTxs(String[] zpubs, int page, int count) throws Exception { + List txes = new ArrayList<>(); + + for(String zpub : zpubs) { + Wallet wallet = getWallet(zpub); + if(wallet == null) { + log.debug("No wallet for " + zpub + " found"); + continue; + } + + for(BlockTransaction blockTransaction : wallet.getTransactions().values()) { + TxsResponse.Tx tx = new TxsResponse.Tx(); + tx.block_height = blockTransaction.getHeight(); + tx.hash = blockTransaction.getHashAsString(); + tx.locktime = blockTransaction.getTransaction().getLocktime(); + tx.time = blockTransaction.getDate().getTime(); + tx.version = (int)blockTransaction.getTransaction().getVersion(); + + tx.inputs = new TxsResponse.TxInput[blockTransaction.getTransaction().getInputs().size()]; + for(int i = 0; i < blockTransaction.getTransaction().getInputs().size(); i++) { + TransactionInput txInput = blockTransaction.getTransaction().getInputs().get(i); + tx.inputs[i] = new TxsResponse.TxInput(); + tx.inputs[i].vin = txInput.getIndex(); + tx.inputs[i].sequence = txInput.getSequenceNumber(); + tx.inputs[i].prev_out = new TxsResponse.TxOut(); + tx.inputs[i].prev_out.txid = txInput.getOutpoint().getHash().toString(); + tx.inputs[i].prev_out.vout = (int)txInput.getOutpoint().getIndex(); + + BlockTransaction spentTransaction = wallet.getTransactions().get(txInput.getOutpoint().getHash()); + if(spentTransaction != null) { + TransactionOutput spentOutput = spentTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); + tx.inputs[i].prev_out.value = spentOutput.getValue(); + Address[] addresses = spentOutput.getScript().getToAddresses(); + if(addresses.length > 0) { + tx.inputs[i].prev_out.addr = addresses[0].toString(); + } + } + } + + tx.out = new TxsResponse.TxOutput[blockTransaction.getTransaction().getOutputs().size()]; + for(int i = 0; i < blockTransaction.getTransaction().getOutputs().size(); i++) { + TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get(i); + tx.out[i].n = txOutput.getIndex(); + tx.out[i].value = txOutput.getValue(); + Address[] addresses = txOutput.getScript().getToAddresses(); + if(addresses.length > 0) { + tx.out[i].addr = addresses[0].toString(); + } + } + + txes.add(tx); + } + } + + List pageTxes; + if(txes.size() < count) { + pageTxes = txes; + } else { + pageTxes = txes.subList(page * count, Math.min((page * count) + count, txes.size())); + } + + TxsResponse txsResponse = new TxsResponse(); + txsResponse.n_tx = txes.size(); + txsResponse.page = page; + txsResponse.n_tx_page = pageTxes.size(); + txsResponse.txs = pageTxes.toArray(new TxsResponse.Tx[0]); + + return txsResponse; + } + + @Override + public WalletResponse fetchWallet(String[] zpubs) throws Exception { + WalletResponse walletResponse = new WalletResponse(); + walletResponse.wallet = new WalletResponse.Wallet(); + + Map allTransactions = new HashMap<>(); + Map allTransactionsZpubs = new HashMap<>(); + List addresses = new ArrayList<>(); + List txes = new ArrayList<>(); + List unspentOutputs = new ArrayList<>(); + int storedBlockHeight = 0; + + for(String zpub : zpubs) { + Wallet wallet = getWallet(zpub); + if(wallet == null) { + log.debug("No wallet for " + zpub + " found"); + continue; + } + + allTransactions.putAll(wallet.getTransactions()); + wallet.getTransactions().keySet().forEach(txid -> allTransactionsZpubs.put(txid, zpub)); + if(wallet.getStoredBlockHeight() != null) { + storedBlockHeight = Math.max(storedBlockHeight, wallet.getStoredBlockHeight()); + } + + WalletResponse.Address address = new WalletResponse.Address(); + List headers = ExtendedKey.Header.getHeaders(Network.get()); + ExtendedKey.Header header = headers.stream().filter(head -> head.getDefaultScriptType().equals(wallet.getScriptType()) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); + address.address = wallet.getKeystores().get(0).getExtendedPublicKey().toString(header); + address.account_index = wallet.getNode(KeyPurpose.RECEIVE).getHighestUsedIndex() == null ? 0 : wallet.getNode(KeyPurpose.RECEIVE).getHighestUsedIndex() + 1; + address.change_index = wallet.getNode(KeyPurpose.CHANGE).getHighestUsedIndex() == null ? 0 : wallet.getNode(KeyPurpose.CHANGE).getHighestUsedIndex() + 1; + address.n_tx = wallet.getTransactions().size(); + addresses.add(address); + + for(Map.Entry utxo : wallet.getWalletUtxos().entrySet()) { + BlockTransaction blockTransaction = wallet.getTransactions().get(utxo.getKey().getHash()); + if(blockTransaction != null) { + unspentOutputs.add(Whirlpool.getUnspentOutput(wallet, utxo.getValue(), blockTransaction, (int)utxo.getKey().getIndex())); + } + } + } + + for(BlockTransaction blockTransaction : allTransactions.values()) { + WalletResponse.Tx tx = new WalletResponse.Tx(); + tx.block_height = blockTransaction.getHeight(); + tx.hash = blockTransaction.getHashAsString(); + tx.locktime = blockTransaction.getTransaction().getLocktime(); + tx.version = (int)blockTransaction.getTransaction().getVersion(); + + tx.inputs = new WalletResponse.TxInput[blockTransaction.getTransaction().getInputs().size()]; + for(int i = 0; i < blockTransaction.getTransaction().getInputs().size(); i++) { + TransactionInput txInput = blockTransaction.getTransaction().getInputs().get(i); + tx.inputs[i] = new WalletResponse.TxInput(); + tx.inputs[i].vin = txInput.getIndex(); + tx.inputs[i].sequence = txInput.getSequenceNumber(); + if(allTransactionsZpubs.containsKey(txInput.getOutpoint().getHash())) { + tx.inputs[i].prev_out = new WalletResponse.TxOut(); + tx.inputs[i].prev_out.txid = txInput.getOutpoint().getHash().toString(); + tx.inputs[i].prev_out.vout = (int)txInput.getOutpoint().getIndex(); + + BlockTransaction spentTransaction = allTransactions.get(txInput.getOutpoint().getHash()); + if(spentTransaction != null) { + TransactionOutput spentOutput = spentTransaction.getTransaction().getOutputs().get((int)txInput.getOutpoint().getIndex()); + tx.inputs[i].prev_out.value = spentOutput.getValue(); + } + + tx.inputs[i].prev_out.xpub = new UnspentOutput.Xpub(); + tx.inputs[i].prev_out.xpub.m = allTransactionsZpubs.get(txInput.getOutpoint().getHash()); + } + } + + tx.out = new WalletResponse.TxOutput[blockTransaction.getTransaction().getOutputs().size()]; + for(int i = 0; i < blockTransaction.getTransaction().getOutputs().size(); i++) { + TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get(i); + tx.out[i] = new WalletResponse.TxOutput(); + tx.out[i].n = txOutput.getIndex(); + tx.out[i].value = txOutput.getValue(); + tx.out[i].xpub = new UnspentOutput.Xpub(); + tx.out[i].xpub.m = allTransactionsZpubs.get(blockTransaction.getHash()); + } + + txes.add(tx); + } + + walletResponse.addresses = addresses.toArray(new WalletResponse.Address[0]); + walletResponse.txs = txes.toArray(new WalletResponse.Tx[0]); + walletResponse.unspent_outputs = unspentOutputs.toArray(new UnspentOutput[0]); + + walletResponse.info = new WalletResponse.Info(); + walletResponse.info.latest_block = new WalletResponse.InfoBlock(); + walletResponse.info.latest_block.height = AppServices.getCurrentBlockHeight() == null ? storedBlockHeight : AppServices.getCurrentBlockHeight(); + walletResponse.info.latest_block.hash = Sha256Hash.ZERO_HASH.toString(); + walletResponse.info.latest_block.time = AppServices.getLatestBlockHeader() == null ? 1 : AppServices.getLatestBlockHeader().getTime(); + + walletResponse.info.fees = new LinkedHashMap<>(); + for(MinerFeeTarget target : MinerFeeTarget.values()) { + walletResponse.info.fees.put(target.getValue(), AppServices.getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getMinimumFeeForTarget(Integer.parseInt(target.getValue()))); + } + + return walletResponse; + } + + @Override + public MinerFee fetchMinerFee() throws Exception { + Map fees = new LinkedHashMap<>(); + for(MinerFeeTarget target : MinerFeeTarget.values()) { + fees.put(target.getValue(), AppServices.getTargetBlockFeeRates() == null ? FALLBACK_FEE_RATE : getMinimumFeeForTarget(Integer.parseInt(target.getValue()))); + } + + return new MinerFee(fees); + } + + @Override + public void pushTx(String txHex) throws Exception { + Transaction transaction = new Transaction(Utils.hexToBytes(txHex)); + ElectrumServer electrumServer = new ElectrumServer(); + electrumServer.broadcastTransactionPrivately(transaction); + } + + @Override + public boolean testConnectivity() { + return AppServices.isConnected(); + } + + private Integer getMinimumFeeForTarget(int targetBlocks) { + List> feeRates = new ArrayList<>(AppServices.getTargetBlockFeeRates().entrySet()); + Collections.reverse(feeRates); + for(Map.Entry feeRate : feeRates) { + if(feeRate.getKey() <= targetBlocks) { + return feeRate.getValue().intValue(); + } + } + + return feeRates.get(0).getValue().intValue(); + } + + @Override + public void initBip84(String zpub) throws Exception { + //nothing required + } + + private Wallet getWallet(String zpub) { + return AppServices.get().getOpenWallets().keySet().stream() + .filter(wallet -> { + List headers = ExtendedKey.Header.getHeaders(Network.get()); + ExtendedKey.Header header = headers.stream().filter(head -> head.getDefaultScriptType().equals(wallet.getScriptType()) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); + ExtendedKey.Header p2pkhHeader = headers.stream().filter(head -> head.getDefaultScriptType().equals(ScriptType.P2PKH) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); + ExtendedKey extPubKey = wallet.getKeystores().get(0).getExtendedPublicKey(); + return extPubKey.toString(header).equals(zpub) || extPubKey.toString(p2pkhHeader).equals(zpub); + }) + .findFirst() + .orElse(null); + } + + @Override + public List fetchUtxos(String zpub) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public List fetchUtxos(String[] zpubs) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public Map fetchAddresses(String[] zpubs) throws Exception { + throw new UnsupportedOperationException(); + } + + @Override + public MultiAddrResponse.Address fetchAddress(String zpub) throws Exception { + throw new UnsupportedOperationException(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowMinerFeeSupplier.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowMinerFeeSupplier.java new file mode 100644 index 00000000..68cd7569 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowMinerFeeSupplier.java @@ -0,0 +1,11 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.wallet.api.backend.MinerFee; +import com.samourai.whirlpool.client.wallet.data.minerFee.MinerFeeSupplier; + +public class SparrowMinerFeeSupplier extends MinerFeeSupplier { + public SparrowMinerFeeSupplier(int feeMin, int feeMax, int feeFallback, MinerFee currentMinerFee) { + super(feeMin, feeMax, feeFallback); + setValue(currentMinerFee); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java new file mode 100644 index 00000000..01184f65 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java @@ -0,0 +1,361 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.google.common.eventbus.Subscribe; +import com.google.common.net.HostAndPort; +import com.samourai.tor.client.TorClientService; +import com.samourai.wallet.api.backend.BackendApi; +import com.samourai.wallet.api.backend.beans.UnspentOutput; +import com.samourai.wallet.hd.HD_Wallet; +import com.samourai.wallet.hd.java.HD_WalletFactoryJava; +import com.samourai.whirlpool.client.event.*; +import com.samourai.whirlpool.client.tx0.*; +import com.samourai.whirlpool.client.wallet.WhirlpoolEventService; +import com.samourai.whirlpool.client.wallet.WhirlpoolWallet; +import com.samourai.whirlpool.client.wallet.WhirlpoolWalletConfig; +import com.samourai.whirlpool.client.wallet.WhirlpoolWalletService; +import com.samourai.whirlpool.client.wallet.beans.*; +import com.samourai.whirlpool.client.wallet.data.pool.PoolData; +import com.samourai.whirlpool.client.wallet.data.utxo.UtxoSupplier; +import com.samourai.whirlpool.client.whirlpool.ServerApi; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.nightjar.http.JavaHttpClientService; +import com.sparrowwallet.nightjar.stomp.JavaStompClientService; +import com.sparrowwallet.nightjar.tor.WhirlpoolTorClientService; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import javafx.concurrent.Service; +import javafx.concurrent.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +public class Whirlpool { + private static final Logger log = LoggerFactory.getLogger(Whirlpool.class); + + private final HostAndPort torProxy; + private final WhirlpoolServer whirlpoolServer; + private final JavaHttpClientService httpClientService; + private final JavaStompClientService stompClientService; + private final TorClientService torClientService; + private final WhirlpoolWalletService whirlpoolWalletService; + private final WhirlpoolWalletConfig config; + private HD_Wallet hdWallet; + + public Whirlpool(Network network, HostAndPort torProxy, String sCode, int maxClients, int clientDelay) { + this.torProxy = torProxy; + this.whirlpoolServer = WhirlpoolServer.valueOf(network.getName().toUpperCase()); + this.httpClientService = new JavaHttpClientService(torProxy); + this.stompClientService = new JavaStompClientService(httpClientService); + this.torClientService = new WhirlpoolTorClientService(); + this.whirlpoolWalletService = new WhirlpoolWalletService(); + this.config = computeWhirlpoolWalletConfig(sCode, maxClients, clientDelay); + + WhirlpoolEventService.getInstance().register(this); + } + + private WhirlpoolWalletConfig computeWhirlpoolWalletConfig(String sCode, int maxClients, int clientDelay) { + boolean onion = (torProxy != null); + String serverUrl = whirlpoolServer.getServerUrl(onion); + + ServerApi serverApi = new ServerApi(serverUrl, httpClientService); + BackendApi backendApi = new SparrowBackendApi(); + + WhirlpoolWalletConfig whirlpoolWalletConfig = new WhirlpoolWalletConfig(httpClientService, stompClientService, torClientService, serverApi, whirlpoolServer, false, backendApi); + whirlpoolWalletConfig.setScode(sCode); + + return whirlpoolWalletConfig; + } + + public Collection getPools() throws Exception { + Tx0ParamService tx0ParamService = getTx0ParamService(); + PoolData poolData = new PoolData(config.getServerApi().fetchPools(), tx0ParamService); + return poolData.getPools(); + } + + public Tx0Preview getTx0Preview(Pool pool, Collection utxos) throws Exception { + Tx0Config tx0Config = new Tx0Config(); + tx0Config.setChangeWallet(WhirlpoolAccount.BADBANK); + Tx0FeeTarget tx0FeeTarget = Tx0FeeTarget.BLOCKS_4; + Tx0FeeTarget mixFeeTarget = Tx0FeeTarget.BLOCKS_4; + + Tx0ParamService tx0ParamService = getTx0ParamService(); + + Tx0Service tx0Service = new Tx0Service(config); + return tx0Service.tx0Preview(utxos, tx0Config, tx0ParamService.getTx0Param(pool, tx0FeeTarget, mixFeeTarget)); + } + + public Tx0 broadcastTx0(Pool pool, Collection utxos) throws Exception { + WhirlpoolWallet whirlpoolWallet = getWhirlpoolWallet(); + whirlpoolWallet.start(); + UtxoSupplier utxoSupplier = whirlpoolWallet.getUtxoSupplier(); + List whirlpoolUtxos = utxos.stream().map(ref -> utxoSupplier.findUtxo(ref.getHashAsString(), (int)ref.getIndex())).filter(Objects::nonNull).collect(Collectors.toList()); + + if(whirlpoolUtxos.size() != utxos.size()) { + throw new IllegalStateException("Failed to find UTXOs in Whirlpool wallet"); + } + + Tx0Config tx0Config = new Tx0Config(); + tx0Config.setChangeWallet(WhirlpoolAccount.BADBANK); + Tx0FeeTarget tx0FeeTarget = Tx0FeeTarget.BLOCKS_4; + Tx0FeeTarget mixFeeTarget = Tx0FeeTarget.BLOCKS_4; + + return whirlpoolWallet.tx0(whirlpoolUtxos, pool, tx0Config, tx0FeeTarget, mixFeeTarget); + } + + private Tx0ParamService getTx0ParamService() { + try { + SparrowMinerFeeSupplier minerFeeSupplier = new SparrowMinerFeeSupplier(config.getFeeMin(), config.getFeeMax(), config.getFeeFallback(), config.getBackendApi().fetchMinerFee()); + return new Tx0ParamService(minerFeeSupplier, config); + } catch(Exception e) { + log.error("Error fetching miner fees", e); + } + + return null; + } + + 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_WalletFactoryJava hdWalletFactory = HD_WalletFactoryJava.getInstance(); + byte[] seed = hdWalletFactory.computeSeedFromWords(words); + hdWallet = new HD_Wallet(purpose, words, whirlpoolServer, seed, passphrase, 1); + } catch(Exception e) { + throw new IllegalStateException("Could not create Whirlpool HD wallet ", e); + } + } + + public WhirlpoolWallet getWhirlpoolWallet() throws WhirlpoolException { + if(whirlpoolWalletService.whirlpoolWallet() != null) { + return whirlpoolWalletService.whirlpoolWallet(); + } + + if(hdWallet == null) { + throw new IllegalStateException("Whirlpool HD wallet not added"); + } + + try { + return whirlpoolWalletService.openWallet(config, Utils.hexToBytes(hdWallet.getSeedHex()), hdWallet.getPassphrase()); + } catch(Exception e) { + throw new WhirlpoolException("Could not create whirlpool wallet ", e); + } + } + + public HostAndPort getTorProxy() { + return torProxy; + } + + public boolean hasWallet() { + return hdWallet != null; + } + + public boolean isStarted() { + if(whirlpoolWalletService.whirlpoolWallet() == null) { + return false; + } + + return whirlpoolWalletService.whirlpoolWallet().isStarted(); + } + + public void shutdown() { + whirlpoolWalletService.closeWallet(); + httpClientService.shutdown(); + } + + public static UnspentOutput getUnspentOutput(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) { + TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get(index); + + UnspentOutput out = new UnspentOutput(); + out.tx_hash = txOutput.getHash().toString(); + out.tx_output_n = txOutput.getIndex(); + out.value = txOutput.getValue(); + out.script = Utils.bytesToHex(txOutput.getScriptBytes()); + + try { + out.addr = txOutput.getScript().getToAddresses()[0].toString(); + } catch(Exception e) { + //ignore + } + + Transaction transaction = (Transaction)txOutput.getParent(); + out.tx_version = (int)transaction.getVersion(); + out.tx_locktime = transaction.getLocktime(); + if(AppServices.getCurrentBlockHeight() != null) { + out.confirmations = blockTransaction.getConfirmations(AppServices.getCurrentBlockHeight()); + } + + if(wallet.getKeystores().size() != 1) { + throw new IllegalStateException("Cannot mix outputs from a wallet with multiple keystores"); + } + + UnspentOutput.Xpub xpub = new UnspentOutput.Xpub(); + List headers = ExtendedKey.Header.getHeaders(Network.get()); + ExtendedKey.Header header = headers.stream().filter(head -> head.getDefaultScriptType().equals(wallet.getScriptType()) && !head.isPrivateKey()).findFirst().orElse(ExtendedKey.Header.xpub); + xpub.m = wallet.getKeystores().get(0).getExtendedPublicKey().toString(header); + xpub.path = node.getDerivationPath().toUpperCase(); + + out.xpub = xpub; + + return out; + } + + public String getScode() { + return config.getScode(); + } + + public void setScode(String scode) { + config.setScode(scode); + } + + @Subscribe + public void onMixFail(MixFailEvent e) { + log.info("Mix failed for utxo " + e.getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getWhirlpoolUtxo().getUtxo().tx_output_n); + } + + @Subscribe + public void onMixSuccess(MixSuccessEvent e) { + log.info("Mix success, new utxo " + e.getMixSuccess().getReceiveUtxo().getHash() + ":" + e.getMixSuccess().getReceiveUtxo().getIndex()); + } + + @Subscribe + public void onWalletStart(WalletStartEvent e) { + log.info("Wallet started"); + } + + @Subscribe + public void onWalletStop(WalletStopEvent e) { + log.info("Wallet stopped"); + } + + public static class PoolsService extends Service> { + private final Whirlpool whirlpool; + + public PoolsService(Whirlpool whirlpool) { + this.whirlpool = whirlpool; + } + + @Override + protected Task> createTask() { + return new Task<>() { + protected Collection call() throws Exception { + return whirlpool.getPools(); + } + }; + } + } + + public static class Tx0PreviewService extends Service { + private final Whirlpool whirlpool; + private final Wallet wallet; + private final Pool pool; + private final List utxoEntries; + + public Tx0PreviewService(Whirlpool whirlpool, Wallet wallet, Pool pool, List utxoEntries) { + this.whirlpool = whirlpool; + this.wallet = wallet; + this.pool = pool; + this.utxoEntries = utxoEntries; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected Tx0Preview call() throws Exception { + updateProgress(-1, 1); + updateMessage("Fetching premix transaction..."); + + Collection utxos = utxoEntries.stream().map(utxoEntry -> Whirlpool.getUnspentOutput(wallet, utxoEntry.getNode(), utxoEntry.getBlockTransaction(), (int)utxoEntry.getHashIndex().getIndex())).collect(Collectors.toList()); + return whirlpool.getTx0Preview(pool, utxos); + } + }; + } + } + + public static class Tx0BroadcastService extends Service { + private final Whirlpool whirlpool; + private final Pool pool; + private final Collection utxos; + + public Tx0BroadcastService(Whirlpool whirlpool, Pool pool, Collection utxos) { + this.whirlpool = whirlpool; + this.pool = pool; + this.utxos = utxos; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected Sha256Hash call() throws Exception { + updateProgress(-1, 1); + updateMessage("Broadcasting premix transaction..."); + + Tx0 tx0 = whirlpool.broadcastTx0(pool, utxos); + return Sha256Hash.wrap(tx0.getTxid()); + } + }; + } + } + + public static class StartupService extends Service { + private final Whirlpool whirlpool; + + public StartupService(Whirlpool whirlpool) { + this.whirlpool = whirlpool; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected WhirlpoolWallet call() throws Exception { + updateProgress(-1, 1); + updateMessage("Starting Whirlpool..."); + + WhirlpoolWallet whirlpoolWallet = whirlpool.getWhirlpoolWallet(); + if(AppServices.onlineProperty().get()) { + whirlpoolWallet.start(); + } + + return whirlpoolWallet; + } + }; + } + } + + public static class ShutdownService extends Service { + private final Whirlpool whirlpool; + + public ShutdownService(Whirlpool whirlpool) { + this.whirlpool = whirlpool; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected Boolean call() throws Exception { + updateProgress(-1, 1); + updateMessage("Disconnecting from Whirlpool..."); + + whirlpool.shutdown(); + return true; + } + }; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java new file mode 100644 index 00000000..91aa1cb1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java @@ -0,0 +1,253 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.whirlpool.client.tx0.Tx0Preview; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.BitcoinUnit; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.control.CoinLabel; +import com.sparrowwallet.sparrow.io.Config; +import com.sparrowwallet.sparrow.wallet.Entry; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.fxml.FXML; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.util.StringConverter; + +import java.util.*; + +public class WhirlpoolController { + @FXML + private VBox whirlpoolBox; + + @FXML + private VBox step1; + + @FXML + private VBox step2; + + @FXML + private VBox step3; + + @FXML + private VBox step4; + + @FXML + private TextField scode; + + @FXML + private ComboBox pool; + + @FXML + private VBox selectedPool; + + @FXML + private CoinLabel poolFee; + + @FXML + private Label poolInsufficient; + + @FXML + private Label poolAnonset; + + @FXML + private HBox discountFeeBox; + + @FXML + private HBox nbOutputsBox; + + @FXML + private Label nbOutputsLoading; + + @FXML + private Label nbOutputs; + + @FXML + private CoinLabel discountFee; + + private String walletId; + private Wallet wallet; + private List utxoEntries; + private final ObjectProperty tx0PreviewProperty = new SimpleObjectProperty<>(null); + + public void initializeView(String walletId, Wallet wallet, List utxoEntries) { + this.walletId = walletId; + this.wallet = wallet; + this.utxoEntries = utxoEntries; + + step1.managedProperty().bind(step1.visibleProperty()); + step2.managedProperty().bind(step2.visibleProperty()); + step3.managedProperty().bind(step3.visibleProperty()); + step4.managedProperty().bind(step4.visibleProperty()); + + step2.setVisible(false); + step3.setVisible(false); + step4.setVisible(false); + + scode.setText(Config.get().getScode() == null ? "" : Config.get().getScode()); + scode.textProperty().addListener((observable, oldValue, newValue) -> { + Config.get().setScode(newValue); + }); + + pool.setConverter(new StringConverter() { + @Override + public String toString(Pool pool) { + return pool == null ? "Fetching pools..." : pool.getPoolId().replace("btc", " BTC"); + } + + @Override + public Pool fromString(String string) { + return null; + } + }); + + pool.valueProperty().addListener((observable, oldValue, newValue) -> { + if(newValue == null) { + selectedPool.setVisible(false); + } else { + poolFee.setValue(newValue.getFeeValue()); + poolAnonset.setText(newValue.getMixAnonymitySet() + " UTXOs"); + selectedPool.setVisible(true); + fetchTx0Preview(newValue); + } + }); + + step4.visibleProperty().addListener((observable, oldValue, newValue) -> { + if(newValue && pool.getItems().isEmpty()) { + fetchPools(); + } + }); + + selectedPool.managedProperty().bind(selectedPool.visibleProperty()); + selectedPool.setVisible(false); + pool.managedProperty().bind(pool.visibleProperty()); + poolInsufficient.managedProperty().bind(poolInsufficient.visibleProperty()); + poolInsufficient.visibleProperty().bind(pool.visibleProperty().not()); + discountFeeBox.managedProperty().bind(discountFeeBox.visibleProperty()); + discountFeeBox.setVisible(false); + nbOutputsBox.managedProperty().bind(nbOutputsBox.visibleProperty()); + nbOutputsBox.setVisible(false); + nbOutputsLoading.managedProperty().bind(nbOutputsLoading.visibleProperty()); + nbOutputs.managedProperty().bind(nbOutputs.visibleProperty()); + nbOutputsLoading.visibleProperty().bind(nbOutputs.visibleProperty().not()); + nbOutputs.setVisible(false); + } + + 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 false; + } + + public boolean back() { + if(step2.isVisible()) { + step2.setVisible(false); + step1.setVisible(true); + return false; + } + + if(step3.isVisible()) { + step3.setVisible(false); + step2.setVisible(true); + return true; + } + + if(step4.isVisible()) { + step4.setVisible(false); + step3.setVisible(true); + return true; + } + + return false; + } + + private void fetchPools() { + long totalUtxoValue = utxoEntries.stream().mapToLong(Entry::getValue).sum(); + Whirlpool.PoolsService poolsService = new Whirlpool.PoolsService(AppServices.get().getWhirlpool(walletId)); + poolsService.setOnSucceeded(workerStateEvent -> { + List availablePools = poolsService.getValue().stream().filter(pool1 -> totalUtxoValue >= (pool1.getPremixValueMin() + pool1.getFeeValue())).toList(); + if(availablePools.isEmpty()) { + pool.setVisible(false); + OptionalLong optMinValue = poolsService.getValue().stream().mapToLong(Pool::getMustMixBalanceMin).min(); + if(optMinValue.isPresent()) { + String satsValue = String.format(Locale.ENGLISH, "%,d", optMinValue.getAsLong()) + " sats"; + String btcValue = CoinLabel.BTC_FORMAT.format((double)optMinValue.getAsLong() / Transaction.SATOSHIS_PER_BITCOIN) + " BTC"; + poolInsufficient.setText("No available pools. Select a value over " + (Config.get().getBitcoinUnit() == BitcoinUnit.BTC ? btcValue : satsValue) + "."); + } + } else { + pool.setDisable(false); + pool.setItems(FXCollections.observableList(availablePools)); + pool.getSelectionModel().select(0); + } + }); + poolsService.setOnFailed(workerStateEvent -> { + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + Optional optButton = AppServices.showErrorDialog("Error fetching pools", exception.getMessage(), ButtonType.CANCEL, new ButtonType("Retry", ButtonBar.ButtonData.APPLY)); + if(optButton.isPresent()) { + if(optButton.get().getButtonData().equals(ButtonBar.ButtonData.APPLY)) { + fetchPools(); + } else { + pool.setDisable(true); + } + } + }); + poolsService.start(); + } + + private void fetchTx0Preview(Pool pool) { + Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId); + whirlpool.setScode(Config.get().getScode()); + + Whirlpool.Tx0PreviewService tx0PreviewService = new Whirlpool.Tx0PreviewService(whirlpool, wallet, pool, utxoEntries); + tx0PreviewService.setOnRunning(workerStateEvent -> { + nbOutputsBox.setVisible(true); + nbOutputsLoading.setText("Calculating..."); + }); + tx0PreviewService.setOnSucceeded(workerStateEvent -> { + Tx0Preview tx0Preview = tx0PreviewService.getValue(); + discountFeeBox.setVisible(tx0Preview.getPool().getFeeValue() != tx0Preview.getTx0Data().getFeeValue()); + discountFee.setValue(tx0Preview.getTx0Data().getFeeValue()); + nbOutputsBox.setVisible(true); + nbOutputs.setText(tx0Preview.getNbPremix() + " UTXOs"); + nbOutputs.setVisible(true); + tx0PreviewProperty.set(tx0Preview); + }); + tx0PreviewService.setOnFailed(workerStateEvent -> { + Throwable exception = workerStateEvent.getSource().getException(); + while(exception.getCause() != null) { + exception = exception.getCause(); + } + + nbOutputsLoading.setText("Error fetching fee: " + exception.getMessage()); + }); + tx0PreviewService.start(); + } + + public ObjectProperty getTx0PreviewProperty() { + return tx0PreviewProperty; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java new file mode 100644 index 00000000..22dfbf15 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java @@ -0,0 +1,81 @@ +package com.sparrowwallet.sparrow.whirlpool; + +import com.samourai.whirlpool.client.tx0.Tx0Preview; +import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.wallet.UtxoEntry; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.event.ActionEvent; +import javafx.fxml.FXMLLoader; +import javafx.scene.control.*; + +import java.io.IOException; +import java.util.List; + +public class WhirlpoolDialog extends Dialog { + public WhirlpoolDialog(String walletId, Wallet wallet, List utxoEntries) { + final DialogPane dialogPane = getDialogPane(); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + AppServices.onEscapePressed(dialogPane.getScene(), this::close); + + try { + FXMLLoader whirlpoolLoader = new FXMLLoader(AppServices.class.getResource("whirlpool/whirlpool.fxml")); + dialogPane.setContent(whirlpoolLoader.load()); + WhirlpoolController whirlpoolController = whirlpoolLoader.getController(); + whirlpoolController.initializeView(walletId, wallet, utxoEntries); + + dialogPane.setPrefWidth(600); + dialogPane.setPrefHeight(520); + AppServices.moveToActiveWindowScreen(this); + + dialogPane.getStylesheets().add(AppServices.class.getResource("whirlpool/whirlpool.css").toExternalForm()); + + final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE); + final ButtonType backButtonType = new javafx.scene.control.ButtonType("Back", ButtonBar.ButtonData.LEFT); + final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + final ButtonType previewButtonType = new javafx.scene.control.ButtonType("Preview Premix", ButtonBar.ButtonData.APPLY); + dialogPane.getButtonTypes().addAll(nextButtonType, backButtonType, cancelButtonType, previewButtonType); + + Button nextButton = (Button)dialogPane.lookupButton(nextButtonType); + Button backButton = (Button)dialogPane.lookupButton(backButtonType); + Button previewButton = (Button)dialogPane.lookupButton(previewButtonType); + previewButton.setDisable(true); + whirlpoolController.getTx0PreviewProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observable, Tx0Preview oldValue, Tx0Preview newValue) { + previewButton.setDisable(newValue == null); + } + }); + + nextButton.managedProperty().bind(nextButton.visibleProperty()); + backButton.managedProperty().bind(backButton.visibleProperty()); + previewButton.managedProperty().bind(previewButton.visibleProperty()); + + backButton.setDisable(true); + previewButton.visibleProperty().bind(nextButton.visibleProperty().not()); + + nextButton.addEventFilter(ActionEvent.ACTION, event -> { + if(!whirlpoolController.next()) { + nextButton.setVisible(false); + previewButton.setDefaultButton(true); + } + backButton.setDisable(false); + event.consume(); + }); + + backButton.addEventFilter(ActionEvent.ACTION, event -> { + nextButton.setVisible(true); + if(!whirlpoolController.back()) { + backButton.setDisable(true); + } + event.consume(); + }); + + setResultConverter(dialogButton -> dialogButton.getButtonData().equals(ButtonBar.ButtonData.APPLY) ? whirlpoolController.getTx0PreviewProperty().get() : null); + } catch(IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolException.java b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolException.java new file mode 100644 index 00000000..8d587f86 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolException.java @@ -0,0 +1,11 @@ +package com.sparrowwallet.sparrow.whirlpool; + +public class WhirlpoolException extends Exception { + public WhirlpoolException(String message) { + super(message); + } + + public WhirlpoolException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index e1670e50..d846e1e1 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -37,4 +37,5 @@ open module com.sparrowwallet.sparrow { requires com.nativelibs4java.bridj; requires org.reactfx.reactfx; requires dev.bwt.jni; + requires com.sparrowwallet.nightjar; } \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.css b/src/main/resources/com/sparrowwallet/sparrow/app.css index 1c917887..10e5eb5f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.css +++ b/src/main/resources/com/sparrowwallet/sparrow/app.css @@ -28,7 +28,7 @@ -fx-tab-max-height: 0; } -.master-only .tab-header-area { +.master-only > .tab-header-area { visibility: hidden; } diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml index ddcc08dc..6ca8a75f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml @@ -82,7 +82,7 @@

-