initial whirlpool integration

This commit is contained in:
Craig Raw 2021-08-12 17:50:13 +02:00
parent 34b4c39ccd
commit 2caee79df4
39 changed files with 1905 additions and 86 deletions

View file

@ -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')

View file

@ -122,7 +122,11 @@ abstract public class ExtraModuleInfoTransform implements TransformAction<ExtraM
private static void addModuleDescriptor(File originalJar, File moduleJar, ModuleInfo moduleInfo) {
try (JarInputStream inputStream = new JarInputStream(new FileInputStream(originalJar))) {
try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), inputStream.getManifest())) {
Manifest manifest = inputStream.getManifest();
if(manifest == null) {
manifest = new Manifest();
}
try (JarOutputStream outputStream = new JarOutputStream(new FileOutputStream(moduleJar), manifest)) {
copyEntries(inputStream, outputStream);
outputStream.putNextEntry(new JarEntry("module-info.class"));
outputStream.write(addModuleInfo(moduleInfo));

2
drongo

@ -1 +1 @@
Subproject commit 8fd14ce1338328efb6cea4e847766515b5b217a0
Subproject commit eab42c0f0580452968f579bba3904bfc6480ae0f

View file

@ -30,6 +30,7 @@ import com.sparrowwallet.sparrow.transaction.TransactionData;
import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.WalletController;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import de.codecentric.centerdevice.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
@ -819,13 +820,13 @@ public class AppController implements Initializable {
private void openWallet(Storage storage, WalletBackupAndKey walletBackupAndKey, AppController appController, boolean forceSameWindow) {
try {
checkWalletNetwork(walletBackupAndKey.getWallet());
restorePublicKeysFromSeed(walletBackupAndKey.getWallet(), walletBackupAndKey.getKey());
restorePublicKeysFromSeed(storage, walletBackupAndKey.getWallet(), walletBackupAndKey.getKey());
if(!walletBackupAndKey.getWallet().isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
AppController walletAppController = appController.addWalletTabOrWindow(storage, walletBackupAndKey.getWallet(), walletBackupAndKey.getBackupWallet(), forceSameWindow);
for(Map.Entry<Storage, WalletBackupAndKey> entry : walletBackupAndKey.getChildWallets().entrySet()) {
openWallet(entry.getKey(), entry.getValue(), walletAppController, true);
for(Map.Entry<WalletBackupAndKey, Storage> 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);
}
}

View file

@ -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<String, Whirlpool> whirlpoolMap = new HashMap<>();
private static Integer currentBlockHeight;
private static BlockHeader latestBlockHeader;
private static Map<Integer, Double> targetBlockFeeRates;
private static final Map<Date, Set<MempoolRateSize>> 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<Integer, Double> 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) {

View file

@ -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));
}
}

View file

@ -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"));
}

View file

@ -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());
}
}

View file

@ -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<Payment> payments;
private final Long fee;
private final boolean includeSpentMempoolOutputs;
private final Pool pool;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> 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<BlockTransactionHashIndex> utxos, List<Payment> 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;
}
}

View file

@ -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'),

View file

@ -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 {

View file

@ -43,8 +43,8 @@ public class JsonPersistence implements Persistence {
wallet = gson.fromJson(reader, Wallet.class);
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, null);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
Map<WalletBackupAndKey, Storage> 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<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, encryptionKey);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
Map<WalletBackupAndKey, Storage> 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<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException {
private Map<WalletBackupAndKey, Storage> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException {
File[] walletFiles = getChildWalletFiles(storage.getWalletFile(), masterWallet);
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
Map<WalletBackupAndKey, Storage> 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);

View file

@ -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);

View file

@ -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<Storage, WalletBackupAndKey> entry : walletBackupAndKey.getChildWallets().entrySet()) {
entry.getValue().getWallet().decrypt(entry.getValue().getKey());
for(Map.Entry<WalletBackupAndKey, Storage> entry : walletBackupAndKey.getChildWallets().entrySet()) {
entry.getKey().getWallet().decrypt(entry.getKey().getKey());
}
}

View file

@ -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();

View file

@ -5,14 +5,14 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.Map;
public class WalletBackupAndKey {
public class WalletBackupAndKey implements Comparable<WalletBackupAndKey> {
private final Wallet wallet;
private final Wallet backupWallet;
private final ECKey encryptionKey;
private final Key key;
private final Map<Storage, WalletBackupAndKey> childWallets;
private final Map<WalletBackupAndKey, Storage> childWallets;
public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, ECKey encryptionKey, AsymmetricKeyDeriver keyDeriver, Map<Storage, WalletBackupAndKey> childWallets) {
public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, ECKey encryptionKey, AsymmetricKeyDeriver keyDeriver, Map<WalletBackupAndKey, Storage> childWallets) {
this.wallet = wallet;
this.backupWallet = backupWallet;
this.encryptionKey = encryptionKey;
@ -36,7 +36,7 @@ public class WalletBackupAndKey {
return key;
}
public Map<Storage, WalletBackupAndKey> getChildWallets() {
public Map<WalletBackupAndKey, Storage> 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();
}
}

View file

@ -90,20 +90,20 @@ public class DbPersistence implements Persistence {
backupWallet = backupPersistence.loadWallet(new Storage(backupPersistence, backupFile), password, encryptionKey).getWallet();
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, masterWallet, backupWallet, encryptionKey);
masterWallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
Map<WalletBackupAndKey, Storage> 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<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, Wallet backupWallet, ECKey encryptionKey) throws StorageException {
private Map<WalletBackupAndKey, Storage> loadChildWallets(Storage storage, Wallet masterWallet, Wallet backupWallet, ECKey encryptionKey) throws StorageException {
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
List<String> schemas = jdbi.withHandle(handle -> {
return handle.createQuery("show schemas").mapTo(String.class).list();
});
List<String> childSchemas = schemas.stream().filter(schema -> schema.startsWith(WALLET_SCHEMA_PREFIX) && !schema.equals(MASTER_SCHEMA)).collect(Collectors.toList());
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
Map<WalletBackupAndKey, Storage> 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<String> 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);

View file

@ -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<BroadcastSource> 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<Sha256Hash> 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<BroadcastSource> 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);
}
};
}

View file

@ -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.
* <p>
* 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);
}
}

View file

@ -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());

View file

@ -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<UtxoFilter> utxoFilterProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<Pool> whirlpoolProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<WalletTransaction> walletTransactionProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<WalletTransaction> 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<SecureString> 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<BlockTransactionHashIndex, WalletNode> 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<BlockTransactionHashIndex> 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);
}
}

View file

@ -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<Integer>) c -> {
List<Entry> 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<Entry> 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<Entry> 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<HashIndexEntry> 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<Entry> 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<UtxoEntry> utxoEntries = getSelectedUtxos();
final List<BlockTransactionHashIndex> 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<UtxoEntry> selectedEntries = getSelectedUtxos();
WhirlpoolDialog whirlpoolDialog = new WhirlpoolDialog(getWalletForm().getWalletId(), getWalletForm().getWallet(), selectedEntries);
Optional<Tx0Preview> optTx0Preview = whirlpoolDialog.showAndWait();
optTx0Preview.ifPresent(tx0Preview -> previewPremixTransaction(getWalletForm().getWallet(), tx0Preview, selectedEntries));
}
public void previewPremixTransaction(Wallet wallet, Tx0Preview tx0Preview, List<UtxoEntry> 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<Payment> 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<BlockTransactionHashIndex> 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<UtxoEntry> 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());
}
}

View file

@ -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<TxsResponse.Tx> 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<TxsResponse.Tx> 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<Sha256Hash, BlockTransaction> allTransactions = new HashMap<>();
Map<Sha256Hash, String> allTransactionsZpubs = new HashMap<>();
List<WalletResponse.Address> addresses = new ArrayList<>();
List<WalletResponse.Tx> txes = new ArrayList<>();
List<UnspentOutput> 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<ExtendedKey.Header> 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<BlockTransactionHashIndex, WalletNode> 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<String, Integer> 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<Map.Entry<Integer, Double>> feeRates = new ArrayList<>(AppServices.getTargetBlockFeeRates().entrySet());
Collections.reverse(feeRates);
for(Map.Entry<Integer, Double> 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<ExtendedKey.Header> 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<UnspentOutput> fetchUtxos(String zpub) throws Exception {
throw new UnsupportedOperationException();
}
@Override
public List<UnspentOutput> fetchUtxos(String[] zpubs) throws Exception {
throw new UnsupportedOperationException();
}
@Override
public Map<String, MultiAddrResponse.Address> fetchAddresses(String[] zpubs) throws Exception {
throw new UnsupportedOperationException();
}
@Override
public MultiAddrResponse.Address fetchAddress(String zpub) throws Exception {
throw new UnsupportedOperationException();
}
}

View file

@ -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);
}
}

View file

@ -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<Pool> getPools() throws Exception {
Tx0ParamService tx0ParamService = getTx0ParamService();
PoolData poolData = new PoolData(config.getServerApi().fetchPools(), tx0ParamService);
return poolData.getPools();
}
public Tx0Preview getTx0Preview(Pool pool, Collection<UnspentOutput> 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<BlockTransactionHashIndex> utxos) throws Exception {
WhirlpoolWallet whirlpoolWallet = getWhirlpoolWallet();
whirlpoolWallet.start();
UtxoSupplier utxoSupplier = whirlpoolWallet.getUtxoSupplier();
List<WhirlpoolUtxo> 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<String> 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<ExtendedKey.Header> 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<Collection<Pool>> {
private final Whirlpool whirlpool;
public PoolsService(Whirlpool whirlpool) {
this.whirlpool = whirlpool;
}
@Override
protected Task<Collection<Pool>> createTask() {
return new Task<>() {
protected Collection<Pool> call() throws Exception {
return whirlpool.getPools();
}
};
}
}
public static class Tx0PreviewService extends Service<Tx0Preview> {
private final Whirlpool whirlpool;
private final Wallet wallet;
private final Pool pool;
private final List<UtxoEntry> utxoEntries;
public Tx0PreviewService(Whirlpool whirlpool, Wallet wallet, Pool pool, List<UtxoEntry> utxoEntries) {
this.whirlpool = whirlpool;
this.wallet = wallet;
this.pool = pool;
this.utxoEntries = utxoEntries;
}
@Override
protected Task<Tx0Preview> createTask() {
return new Task<>() {
protected Tx0Preview call() throws Exception {
updateProgress(-1, 1);
updateMessage("Fetching premix transaction...");
Collection<UnspentOutput> 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<Sha256Hash> {
private final Whirlpool whirlpool;
private final Pool pool;
private final Collection<BlockTransactionHashIndex> utxos;
public Tx0BroadcastService(Whirlpool whirlpool, Pool pool, Collection<BlockTransactionHashIndex> utxos) {
this.whirlpool = whirlpool;
this.pool = pool;
this.utxos = utxos;
}
@Override
protected Task<Sha256Hash> 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<WhirlpoolWallet> {
private final Whirlpool whirlpool;
public StartupService(Whirlpool whirlpool) {
this.whirlpool = whirlpool;
}
@Override
protected Task<WhirlpoolWallet> 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<Boolean> {
private final Whirlpool whirlpool;
public ShutdownService(Whirlpool whirlpool) {
this.whirlpool = whirlpool;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws Exception {
updateProgress(-1, 1);
updateMessage("Disconnecting from Whirlpool...");
whirlpool.shutdown();
return true;
}
};
}
}
}

View file

@ -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> 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<UtxoEntry> utxoEntries;
private final ObjectProperty<Tx0Preview> tx0PreviewProperty = new SimpleObjectProperty<>(null);
public void initializeView(String walletId, Wallet wallet, List<UtxoEntry> 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<Pool>() {
@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<Pool> 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<ButtonType> 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<Tx0Preview> getTx0PreviewProperty() {
return tx0PreviewProperty;
}
}

View file

@ -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<Tx0Preview> {
public WhirlpoolDialog(String walletId, Wallet wallet, List<UtxoEntry> 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<Tx0Preview>() {
@Override
public void changed(ObservableValue<? extends Tx0Preview> 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);
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -28,7 +28,7 @@
-fx-tab-max-height: 0;
}
.master-only .tab-header-area {
.master-only > .tab-header-area {
visibility: hidden;
}

View file

@ -82,7 +82,7 @@
<Form GridPane.columnIndex="2" GridPane.rowIndex="0">
<Fieldset inputGrow="ALWAYS" style="-fx-padding: 5 0 0 0">
<HBox>
<Button text="" onAction="#scanQrAddress" prefHeight="30">
<Button text="" fx:id="scanQrButton" onAction="#scanQrAddress" prefHeight="30">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>

View file

@ -152,6 +152,11 @@
<HBox AnchorPane.rightAnchor="10">
<Button fx:id="clearButton" text="Clear" cancelButton="true" onAction="#clear" />
<Region HBox.hgrow="ALWAYS" style="-fx-min-width: 20px" />
<Button fx:id="premixButton" text="Broadcast Premix Transaction" contentDisplay="RIGHT" graphicTextGap="5" onAction="#broadcastPremix">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="RANDOM" />
</graphic>
</Button>
<Button fx:id="createButton" text="Create Transaction" defaultButton="true" disable="true" contentDisplay="RIGHT" graphicTextGap="5" onAction="#createTransaction">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="ANGLE_DOUBLE_RIGHT" />

View file

@ -40,6 +40,11 @@
<bottom>
<HBox styleClass="utxos-buttons-box" spacing="20" alignment="BOTTOM_RIGHT">
<Button text="Clear" onAction="#clear"/>
<Button fx:id="mixSelected" text="Mix Selected" graphicTextGap="5" onAction="#mixSelected">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="RANDOM" fontSize="12" />
</graphic>
</Button>
<Button fx:id="sendSelected" text="Send Selected" graphicTextGap="5" onAction="#sendSelected">
<graphic>
<Glyph fontFamily="FontAwesome" icon="SEND" fontSize="12" />

View file

@ -0,0 +1,42 @@
.whirlpool-pane {
-fx-padding: 0;
}
.title-area {
-fx-background-color: -fx-control-inner-background;
-fx-padding: 10 25 10 25;
-fx-border-width: 0px 0px 1px 0px;
-fx-border-color: #e5e5e6;
}
#whirlpoolBox, .button-bar {
-fx-padding: 10 25 25 25;
}
.button-bar .container {
-fx-padding: 0 0 15px 0;
}
.title-label {
-fx-font-size: 24px;
}
.title-text {
-fx-font-size: 20px;
-fx-padding: 0 0 15px 0;
-fx-graphic-text-gap: 10px;
}
.content-text {
-fx-font-size: 16px;
-fx-text-fill: derive(-fx-text-base-color, 15%);
}
.field-box {
-fx-pref-height: 30px;
-fx-alignment: CENTER_LEFT;
}
.field-label {
-fx-pref-width: 120px;
}

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.CoinLabel?>
<StackPane prefHeight="460.0" prefWidth="600.0" stylesheets="@whirlpool.css, @../general.css" styleClass="whirlpool-pane" fx:controller="com.sparrowwallet.sparrow.whirlpool.WhirlpoolController" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<VBox spacing="20">
<HBox styleClass="title-area">
<HBox alignment="CENTER_LEFT">
<Label fx:id="title" text="Whirlpool Configuration" styleClass="title-label" />
</HBox>
<Region HBox.hgrow="ALWAYS"/>
<ImageView AnchorPane.rightAnchor="0">
<Image url="/image/whirlpool.png" requestedWidth="50" requestedHeight="50" smooth="false" />
</ImageView>
</HBox>
<VBox fx:id="whirlpoolBox" styleClass="content-area" spacing="20" prefHeight="370">
<VBox fx:id="step1" spacing="15">
<Label text="Introduction" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Mixing (CoinJoin) is provided in Sparrow through the Samourai Whirlpool coordinator. " wrapText="true" styleClass="content-text" />
<Label text="Sparrow contains a Whirlpool client, which communicates with the coordinator using blinded inputs. As such, the privacy of your UTXOs is unchanged when using this service. If you are using Tor to connect to your server, or have configured a Tor proxy, communication with the coordinator will be over Tor." wrapText="true" styleClass="content-text" />
<Label text="The fees for using the Whirlpool service are deducted from the UTXOs that you mix. These fees include the Whirlpool fee, and the miner fees required for the transactions. All fees are displayed for review before mixing begins." wrapText="true" styleClass="content-text" />
</VBox>
<VBox fx:id="step2" spacing="15">
<Label text="Premix, Postmix and Badbank" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Initiating your first CoinJoin in Sparrow will add three new wallets to your existing wallet: Premix, Postmix and Badbank." wrapText="true" styleClass="content-text" />
<Label text="Premix contains UTXOs that have been split from your deposit UTXOs into equal amounts, waiting for their first mixing round. Postmix contains UTXOs that have been through at least one mixing round. Badbank contains any change from your premix transaction." wrapText="true" styleClass="content-text" />
<Label text="Click on the tabs at the right of the wallet to use these wallets. Note that they will have reduced functionality (for example they will not display receiving addresses)." wrapText="true" styleClass="content-text" />
</VBox>
<VBox fx:id="step3" spacing="15">
<Label text="Configure Whirlpool" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Configure Whirlpool using the fields below. You can enter a Samourai SCODE for reduced cost mixing." wrapText="true" styleClass="content-text" />
<HBox styleClass="field-box">
<padding>
<Insets top="20" />
</padding>
<Label text="SCODE:" styleClass="field-label" />
<TextField fx:id="scode" />
</HBox>
</VBox>
<VBox fx:id="step4" spacing="15">
<Label text="Select Pool" styleClass="title-text">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="20" icon="RANDOM" styleClass="title-icon" />
</graphic>
</Label>
<Label text="Choose which pool to use below. You will then be able to preview your premix transaction." wrapText="true" styleClass="content-text" />
<HBox spacing="20" alignment="CENTER_LEFT">
<padding>
<Insets top="20" bottom="5" />
</padding>
<Label text="Pool:" prefWidth="100"/>
<ComboBox fx:id="pool" />
<Label fx:id="poolInsufficient" text="No available pools." styleClass="failure"/>
</HBox>
<VBox fx:id="selectedPool" spacing="15">
<HBox styleClass="field-box">
<Label text="Anonset:" styleClass="field-label" />
<Label fx:id="poolAnonset" />
</HBox>
<HBox styleClass="field-box">
<Label text="Pool Fee:" styleClass="field-label" />
<CoinLabel fx:id="poolFee" />
<HBox fx:id="discountFeeBox" alignment="CENTER_LEFT">
<Label text=" (discounted to " />
<CoinLabel fx:id="discountFee" />
<Label text=")" />
</HBox>
</HBox>
<HBox fx:id="nbOutputsBox" styleClass="field-box">
<Label text="Premix Outputs:" styleClass="field-label" />
<Label fx:id="nbOutputsLoading" text="Calculating..." />
<Label fx:id="nbOutputs" />
</HBox>
</VBox>
</VBox>
</VBox>
</VBox>
</StackPane>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -17,6 +17,23 @@
<logger name="org.flywaydb.core.internal.database.base.Schema" level="WARN" />
<logger name="org.flywaydb.core.internal.schemahistory.JdbcTableSchemaHistory" level="WARN" />
<logger name="org.flywaydb.core.internal.license.VersionPrinter" level="WARN" />
<logger name="org.eclipse.jetty.util.thread.ReservedThreadExecutor" level="OFF" />
<logger name="org.eclipse.jetty.util.thread.QueuedThreadPool" level="OFF" />
<logger name="org.eclipse.jetty.io.ManagedSelector" level="OFF" />
<logger name="org.eclipse.jetty.util.component.AbstractLifeCycle" level="OFF" />
<logger name="org.eclipse.jetty.io.ChannelEndPoint" level="OFF" />
<logger name="org.eclipse.jetty.io.ssl.SslConnection" level="OFF" />
<logger name="org.eclipse.jetty.client.HttpReceiver" level="OFF" />
<logger name="org.eclipse.jetty.client.TimeoutCompleteListener" level="OFF" />
<logger name="org.eclipse.jetty.client.AbstractConnectionPool" level="OFF" />
<logger name="org.eclipse.jetty.client.HttpDestination" level="OFF" />
<logger name="org.eclipse.jetty.client.HttpChannel" level="OFF" />
<logger name="org.eclipse.jetty.client.HttpExchange" level="OFF" />
<logger name="org.eclipse.jetty.client.HttpParser" level="OFF" />
<logger name="org.eclipse.jetty.http.HttpParser" level="OFF" />
<logger name="org.eclipse.jetty.util.log.Log" level="OFF" />
<logger name="org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler" level="OFF" />
<logger name="org.bitcoinj.crypto.MnemonicCode" level="OFF" />
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"/>