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
@@ -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);
}
}
@@ -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) {
@@ -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));
}
}
@@ -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"));
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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'),
@@ -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 {
@@ -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);
@@ -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);
@@ -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());
}
}
@@ -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();
@@ -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();
}
}
@@ -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);
@@ -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);
}
};
}
@@ -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);
}
}
@@ -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());
@@ -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);
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
};
}
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
}
+1
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;
}