Browse Source

Merge remote-tracking branch 'remotes/origin/master' into whirlpool-client-0.23.30-early4

# Conflicts:
#	src/main/java/com/sparrowwallet/sparrow/AppServices.java
#	src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java
terminal
zeroleak 3 years ago
parent
commit
772370808c
  1. 4
      build.gradle
  2. 2
      drongo
  3. 118
      src/main/java/com/sparrowwallet/sparrow/AppController.java
  4. 33
      src/main/java/com/sparrowwallet/sparrow/AppServices.java
  5. 35
      src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java
  6. 2
      src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java
  7. 2
      src/main/java/com/sparrowwallet/sparrow/control/DateCell.java
  8. 6
      src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
  9. 167
      src/main/java/com/sparrowwallet/sparrow/control/MixStatusCell.java
  10. 2
      src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java
  11. 16
      src/main/java/com/sparrowwallet/sparrow/control/TransactionHexArea.java
  12. 32
      src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java
  13. 2
      src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java
  14. 25
      src/main/java/com/sparrowwallet/sparrow/event/NewWalletTransactionsEvent.java
  15. 3
      src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java
  16. 26
      src/main/java/com/sparrowwallet/sparrow/event/WalletUtxoMixesChangedEvent.java
  17. 59
      src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixEvent.java
  18. 19
      src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixSuccessEvent.java
  19. 2
      src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java
  20. 2
      src/main/java/com/sparrowwallet/sparrow/io/SeedSigner.java
  21. 2
      src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java
  22. 25
      src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
  23. 56
      src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataDao.java
  24. 42
      src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataMapper.java
  25. 8
      src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java
  26. 18
      src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
  27. 2
      src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java
  28. 2
      src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
  29. 3
      src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java
  30. 11
      src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
  31. 148
      src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java
  32. 105
      src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java
  33. 12
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java
  34. 61
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
  35. 5
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java
  36. 16
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java
  37. 4
      src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowDataSource.java
  38. 77
      src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowUtxoConfigPersister.java
  39. 24
      src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletDataSupplier.java
  40. 53
      src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletStatePersister.java
  41. 23
      src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWhirlpoolWalletService.java
  42. 183
      src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java
  43. 9
      src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java
  44. 9
      src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java
  45. 13
      src/main/resources/com/sparrowwallet/sparrow/app.css
  46. 2
      src/main/resources/com/sparrowwallet/sparrow/app.fxml
  47. 1
      src/main/resources/com/sparrowwallet/sparrow/sql/V2__Whirlpool.sql
  48. 4
      src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.css
  49. 39
      src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml
  50. 2
      src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css
  51. 42
      src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml
  52. 5
      src/main/resources/logback.xml

4
build.gradle

@ -91,7 +91,7 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.6')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.10')
testImplementation('junit:junit:4.12')
}
@ -387,7 +387,7 @@ 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') {
module('nightjar-0.2.10.jar', 'com.sparrowwallet.nightjar', '0.2.10') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')

2
drongo

@ -1 +1 @@
Subproject commit 2eedd2290cbe1dd559247f1ee934cece81fa7419
Subproject commit 81c202198e8b057271414d15259df556a90bc6f1

118
src/main/java/com/sparrowwallet/sparrow/AppController.java

@ -35,6 +35,8 @@ import de.codecentric.centerdevice.MenuToolkit;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
@ -161,6 +163,10 @@ public class AppController implements Initializable {
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
private final ChangeListener<Boolean> serverToggleOnlineListener = (observable, oldValue, newValue) -> {
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@ -284,9 +290,7 @@ public class AppController implements Initializable {
serverToggle.setSelected(isConnected());
serverToggle.setDisable(Config.get().getServerType() == null);
onlineProperty().bindBidirectional(serverToggle.selectedProperty());
onlineProperty().addListener((observable, oldValue, newValue) -> {
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
});
onlineProperty().addListener(new WeakChangeListener<>(serverToggleOnlineListener));
serverToggle.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
Config.get().setMode(serverToggle.isSelected() ? Mode.ONLINE : Mode.OFFLINE);
});
@ -895,7 +899,7 @@ public class AppController implements Initializable {
if(wallet.isWhirlpoolMasterWallet()) {
String walletId = storage.getWalletId(wallet);
Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId);
whirlpool.setHDWallet(copy);
whirlpool.setHDWallet(storage.getWalletId(wallet), copy);
}
for(int i = 0; i < wallet.getKeystores().size(); i++) {
@ -1185,6 +1189,7 @@ public class AppController implements Initializable {
TabPane subTabs = new TabPane();
subTabs.setSide(Side.RIGHT);
subTabs.getStyleClass().add("master-only");
subTabs.rotateGraphicProperty().set(true);
tab.setContent(subTabs);
WalletForm walletForm = addWalletSubTab(subTabs, storage, wallet, backupWallet);
@ -1213,7 +1218,13 @@ public class AppController implements Initializable {
if(walletTabData.getWallet() == wallet.getMasterWallet()) {
TabPane subTabs = (TabPane)walletTab.getContent();
addWalletSubTab(subTabs, storage, wallet, backupWallet);
Platform.runLater(() -> subTabs.getStyleClass().remove("master-only"));
Tab masterTab = subTabs.getTabs().get(0);
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(getAutomaticName(wallet.getMasterWallet()));
Platform.runLater(() -> {
subTabs.getStyleClass().remove("master-only");
subTabs.getStyleClass().add("wallet-subtabs");
});
}
}
}
@ -1222,8 +1233,13 @@ public class AppController implements Initializable {
public WalletForm addWalletSubTab(TabPane subTabs, Storage storage, Wallet wallet, Wallet backupWallet) {
try {
Tab subTab = new Tab(wallet.isMasterWallet() ? getAutomaticName(wallet) : wallet.getName());
Tab subTab = new Tab();
subTab.setClosable(false);
Label subTabLabel = new Label(wallet.isMasterWallet() ? getAutomaticName(wallet) : wallet.getName());
subTabLabel.setGraphic(getSubTabGlyph(wallet));
subTabLabel.setContentDisplay(ContentDisplay.TOP);
subTabLabel.setAlignment(Pos.TOP_CENTER);
subTab.setGraphic(subTabLabel);
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
subTab.setContent(walletLoader.load());
WalletController controller = walletLoader.getController();
@ -1247,9 +1263,26 @@ public class AppController implements Initializable {
}
}
private Glyph getSubTabGlyph(Wallet wallet) {
Glyph tabGlyph;
StandardAccount standardAccount = wallet.getStandardAccountType();
if(standardAccount == StandardAccount.WHIRLPOOL_PREMIX) {
tabGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
} else if(standardAccount == StandardAccount.WHIRLPOOL_POSTMIX) {
tabGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
} else if(standardAccount == StandardAccount.WHIRLPOOL_BADBANK) {
tabGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.BIOHAZARD);
} else {
tabGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
}
tabGlyph.setFontSize(12);
return tabGlyph;
}
private String getAutomaticName(Wallet wallet) {
int account = wallet.getAccountIndex();
return account < 0 ? wallet.getName() : "Account #" + account;
return account < 0 ? wallet.getName() : (!wallet.isWhirlpoolMasterWallet() || account > 1 ? "Account #" + account : "Deposit");
}
public WalletForm getSelectedWalletForm() {
@ -1637,9 +1670,30 @@ public class AppController implements Initializable {
@Subscribe
public void newWalletTransactions(NewWalletTransactionsEvent event) {
if(Config.get().isNotifyNewTransactions() && getOpenWallets().containsKey(event.getWallet())) {
String text;
if(event.getBlockTransactions().size() == 1) {
BlockTransaction blockTransaction = event.getBlockTransactions().get(0);
List<BlockTransaction> blockTransactions = new ArrayList<>(event.getBlockTransactions());
List<BlockTransaction> whirlpoolTransactions = event.getWhirlpoolMixTransactions();
blockTransactions.removeAll(whirlpoolTransactions);
if(!whirlpoolTransactions.isEmpty()) {
BlockTransaction blockTransaction = whirlpoolTransactions.get(0);
String status;
String walletName = event.getWallet().getMasterName() + " " + event.getWallet().getName().toLowerCase();
long value = blockTransaction.getTransaction().getOutputs().iterator().next().getValue();
long mempoolValue = whirlpoolTransactions.stream().filter(tx -> tx.getHeight() <= 0).mapToLong(tx -> value).sum();
long blockchainValue = whirlpoolTransactions.stream().filter(tx -> tx.getHeight() > 0).mapToLong(tx -> value).sum();
if(mempoolValue > 0) {
status = "New " + walletName + " mempool transaction" + (mempoolValue > value ? "s: " : ": ") + event.getValueAsText(mempoolValue);
} else {
status = "Confirming " + walletName + " transaction" + (blockchainValue > value ? "s: " : ": ") + event.getValueAsText(blockchainValue);
}
statusUpdated(new StatusEvent(status));
}
String text = null;
if(blockTransactions.size() == 1) {
BlockTransaction blockTransaction = blockTransactions.get(0);
if(blockTransaction.getHeight() <= 0) {
text = "New mempool transaction: ";
} else {
@ -1654,7 +1708,7 @@ public class AppController implements Initializable {
}
text += event.getValueAsText(event.getTotalValue());
} else {
} else if(blockTransactions.size() > 1) {
if(event.getTotalBlockchainValue() > 0 && event.getTotalMempoolValue() > 0) {
text = "New transactions: " + event.getValueAsText(event.getTotalValue()) + " total (" + event.getValueAsText(event.getTotalMempoolValue()) + " in mempool)";
} else if(event.getTotalMempoolValue() > 0) {
@ -1664,29 +1718,31 @@ public class AppController implements Initializable {
}
}
Window.getWindows().forEach(window -> {
String notificationStyles = AppController.class.getResource("notificationpopup.css").toExternalForm();
if(!window.getScene().getStylesheets().contains(notificationStyles)) {
window.getScene().getStylesheets().add(notificationStyles);
}
});
if(text != null) {
Window.getWindows().forEach(window -> {
String notificationStyles = AppController.class.getResource("notificationpopup.css").toExternalForm();
if(!window.getScene().getStylesheets().contains(notificationStyles)) {
window.getScene().getStylesheets().add(notificationStyles);
}
});
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
Notifications notificationBuilder = Notifications.create()
.title("Sparrow - " + event.getWallet().getFullName())
.text(text)
.graphic(new ImageView(image))
.hideAfter(Duration.seconds(15))
.position(Pos.TOP_RIGHT)
.threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new ImageView(image)))
.onAction(e -> selectTab(event.getWallet()));
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
Notifications notificationBuilder = Notifications.create()
.title("Sparrow - " + event.getWallet().getFullName())
.text(text)
.graphic(new ImageView(image))
.hideAfter(Duration.seconds(15))
.position(Pos.TOP_RIGHT)
.threshold(5, Notifications.create().title("Sparrow").text("Multiple new wallet transactions").graphic(new ImageView(image)))
.onAction(e -> selectTab(event.getWallet()));
//If controlsfx can't find our window, we must set the window ourselves (unfortunately notification is then shown within this window)
if(org.controlsfx.tools.Utils.getWindow(null) == null) {
notificationBuilder.owner(tabs.getScene().getWindow());
}
//If controlsfx can't find our window, we must set the window ourselves (unfortunately notification is then shown within this window)
if(org.controlsfx.tools.Utils.getWindow(null) == null) {
notificationBuilder.owner(tabs.getScene().getWindow());
notificationBuilder.show();
}
notificationBuilder.show();
}
}

33
src/main/java/com/sparrowwallet/sparrow/AppServices.java

@ -452,6 +452,19 @@ public class AppServices {
return application;
}
public Whirlpool getWhirlpool(Wallet wallet) {
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
for(WalletTabData walletTabData : walletTabDataList) {
if(walletTabData.getWallet() == masterWallet) {
return whirlpoolMap.get(walletTabData.getWalletForm().getWalletId());
}
}
}
return null;
}
public Whirlpool getWhirlpool(String walletId) {
Whirlpool whirlpool = whirlpoolMap.get(walletId);
if(whirlpool == null) {
@ -473,11 +486,11 @@ public class AppServices {
}
}
private void stopAllWhirlpool() {
private void shutdownAllWhirlpool() {
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());
log.error("Failed to shutdown whirlpool", workerStateEvent.getSource().getException());
});
shutdownService.start();
}
@ -511,6 +524,10 @@ public class AppServices {
return openWallets;
}
public Wallet getWallet(String walletId) {
return getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElse(null);
}
public Window getWindowForWallet(String walletId) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWalletForm().getWalletId().equals(walletId))).map(Map.Entry::getKey).findFirst();
return optWindow.orElse(null);
@ -824,7 +841,7 @@ public class AppServices {
@Subscribe
public void disconnection(DisconnectionEvent event) {
stopAllWhirlpool();
shutdownAllWhirlpool();
}
@Subscribe
@ -975,7 +992,7 @@ public class AppServices {
WhirlpoolEventService.getInstance().unregister(whirlpool);
});
shutdownService.setOnFailed(workerStateEvent -> {
log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException());
log.error("Failed to shutdown whirlpool", workerStateEvent.getSource().getException());
});
shutdownService.start();
} else {
@ -987,6 +1004,14 @@ public class AppServices {
}
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
Whirlpool whirlpool = getWhirlpool(event.getWallet());
if(whirlpool != null) {
whirlpool.refreshUtxos();
}
}
private void restartBwt(Wallet wallet) {
if(Config.get().getServerType() == ServerType.BITCOIN_CORE && isConnected() && wallet.isValid()) {
connectionService.cancel();

35
src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java

@ -11,55 +11,44 @@ import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import org.controlsfx.glyphfont.Glyph;
public class AddressCell extends TreeTableCell<Entry, Entry> {
public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
public AddressCell() {
super();
setAlignment(Pos.CENTER_LEFT);
setContentDisplay(ContentDisplay.RIGHT);
getStyleClass().add("address-cell");
}
@Override
protected void updateItem(Entry entry, boolean empty) {
super.updateItem(entry, empty);
protected void updateItem(UtxoEntry.AddressStatus addressStatus, boolean empty) {
super.updateItem(addressStatus, empty);
EntryCell.applyRowStyles(this, entry);
getStyleClass().add("address-cell");
UtxoEntry utxoEntry = addressStatus == null ? null : addressStatus.getUtxoEntry();
EntryCell.applyRowStyles(this, utxoEntry);
if (empty) {
setText(null);
setGraphic(null);
} else {
if(entry instanceof UtxoEntry) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
Address address = utxoEntry.getAddress();
if(utxoEntry != null) {
Address address = addressStatus.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
Tooltip tooltip = new Tooltip();
tooltip.setText(getTooltipText(utxoEntry));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate()));
setTooltip(tooltip);
if(utxoEntry.isDuplicateAddress()) {
if(addressStatus.isDuplicate()) {
setGraphic(getDuplicateGlyph());
} else {
setGraphic(null);
}
utxoEntry.duplicateAddressProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
setGraphic(getDuplicateGlyph());
Tooltip tt = new Tooltip();
tt.setText(getTooltipText(utxoEntry));
setTooltip(tt);
} else {
setGraphic(null);
}
});
}
}
}
private String getTooltipText(UtxoEntry utxoEntry) {
return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (utxoEntry.isDuplicateAddress() ? " (Duplicate address)" : "");
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate) {
return utxoEntry.getNode().getDerivationPath().replace("m", "..") + (duplicate ? " (Duplicate address)" : "");
}
public static Glyph getDuplicateGlyph() {

2
src/main/java/com/sparrowwallet/sparrow/control/AddressTreeTable.java

@ -34,7 +34,7 @@ public class AddressTreeTable extends CoinTreeTable {
addressCol.setSortable(false);
getColumns().add(addressCol);
if(address != null) {
if(address != null && !rootEntry.getWallet().isWhirlpoolMixWallet()) {
addressCol.setMinWidth(TextUtils.computeTextWidth(AppServices.getMonospaceFont(), address, 0.0));
}

2
src/main/java/com/sparrowwallet/sparrow/control/DateCell.java

@ -34,7 +34,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
if(entry instanceof UtxoEntry) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.getWallet().isWhirlpoolMixWallet() ? "(Not yet mixable)" : (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)")));
} else {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);

6
src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java

@ -130,6 +130,12 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
setGraphic(actionBox);
if(nodeEntry.getWallet().isWhirlpoolMixWallet()) {
setText(address.toString().substring(0, 20) + "...");
setContextMenu(null);
setGraphic(new HBox());
}
} else if(entry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
setText(hashIndexEntry.getDescription());

167
src/main/java/com/sparrowwallet/sparrow/control/MixStatusCell.java

@ -0,0 +1,167 @@
package com.sparrowwallet.sparrow.control;
import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.mix.listener.MixStep;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolException;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import org.controlsfx.glyphfont.Glyph;
public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
public MixStatusCell() {
super();
setAlignment(Pos.CENTER_RIGHT);
setContentDisplay(ContentDisplay.LEFT);
setGraphicTextGap(8);
getStyleClass().add("mixstatus-cell");
}
@Override
protected void updateItem(UtxoEntry.MixStatus mixStatus, boolean empty) {
super.updateItem(mixStatus, empty);
EntryCell.applyRowStyles(this, mixStatus == null ? null : mixStatus.getUtxoEntry());
if(empty || mixStatus == null) {
setText(null);
setGraphic(null);
} else {
setText(Integer.toString(mixStatus.getMixesDone()));
if(mixStatus.getNextMixUtxo() == null) {
setContextMenu(new MixStatusContextMenu(mixStatus.getUtxoEntry(), mixStatus.getMixProgress() != null && mixStatus.getMixProgress().getMixStep() != MixStep.FAIL));
} else {
setContextMenu(null);
}
if(mixStatus.getPoolId() != null) {
Tooltip tooltip = new Tooltip();
tooltip.setText("Pool: " + mixStatus.getPoolId().replace("btc", " BTC"));
setTooltip(tooltip);
}
if(mixStatus.getNextMixUtxo() != null) {
setMixSuccess(mixStatus.getNextMixUtxo());
} else if(mixStatus.getMixFailReason() != null) {
setMixFail(mixStatus.getMixFailReason());
} else if(mixStatus.getMixProgress() != null) {
setMixProgress(mixStatus.getMixProgress());
} else {
setGraphic(null);
}
}
}
private void setMixSuccess(Utxo nextMixUtxo) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(-1);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
tt.setText("Waiting for broadcast of " + nextMixUtxo.getHash().substring(0, 8) + "..." + ":" + nextMixUtxo.getIndex() );
setTooltip(tt);
}
private void setMixFail(MixFailReason mixFailReason) {
if(mixFailReason != MixFailReason.CANCEL) {
setGraphic(getFailGlyph());
Tooltip tt = new Tooltip();
tt.setText(mixFailReason.getMessage());
setTooltip(tt);
} else {
setGraphic(null);
}
}
private void setMixProgress(MixProgress mixProgress) {
if(mixProgress.getMixStep() != MixStep.FAIL) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(mixProgress.getProgressPercent() == 100 ? -1 : mixProgress.getProgressPercent() / 100.0);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
tt.setText(mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase() + mixProgress.getMixStep().getMessage().substring(1));
setTooltip(tt);
} else {
setGraphic(null);
}
}
private ProgressIndicator getProgressIndicator() {
ProgressIndicator progressIndicator;
if(getGraphic() instanceof ProgressIndicator) {
progressIndicator = (ProgressIndicator)getGraphic();
} else {
progressIndicator = new ProgressBar();
}
return progressIndicator;
}
private static Glyph getMixGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getStopGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.STOP_CIRCLE);
copyGlyph.setFontSize(12);
return copyGlyph;
}
public static Glyph getFailGlyph() {
Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
failGlyph.getStyleClass().add("fail-warning");
failGlyph.setFontSize(12);
return failGlyph;
}
private static class MixStatusContextMenu extends ContextMenu {
public MixStatusContextMenu(UtxoEntry utxoEntry, boolean isMixing) {
Whirlpool pool = AppServices.get().getWhirlpool(utxoEntry.getWallet());
if(isMixing) {
MenuItem mixStop = new MenuItem("Stop Mixing");
if(pool != null) {
mixStop.disableProperty().bind(pool.mixingProperty().not());
}
mixStop.setGraphic(getStopGlyph());
mixStop.setOnAction(event -> {
hide();
Whirlpool whirlpool = AppServices.get().getWhirlpool(utxoEntry.getWallet());
if(whirlpool != null) {
try {
whirlpool.mixStop(utxoEntry.getHashIndex());
} catch(WhirlpoolException e) {
AppServices.showErrorDialog("Error stopping mixing UTXO", e.getMessage());
}
}
});
getItems().add(mixStop);
} else {
MenuItem mixNow = new MenuItem("Mix Now");
if(pool != null) {
mixNow.disableProperty().bind(pool.mixingProperty().not());
}
mixNow.setGraphic(getMixGlyph());
mixNow.setOnAction(event -> {
hide();
Whirlpool whirlpool = AppServices.get().getWhirlpool(utxoEntry.getWallet());
if(whirlpool != null) {
try {
whirlpool.mix(utxoEntry.getHashIndex());
} catch(WhirlpoolException e) {
AppServices.showErrorDialog("Error mixing UTXO", e.getMessage());
}
}
});
getItems().add(mixNow);
}
}
}
}

2
src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java

@ -247,7 +247,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//ignore, bytes not parsable as tx
}
result = new Result(new ScanException("Parsed QR parts were not a PSBT or transaction"));
result = new Result(complete);
}
} else {
PSBT psbt;

16
src/main/java/com/sparrowwallet/sparrow/control/TransactionHexArea.java

@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import javafx.geometry.Point2D;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.IndexRange;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
@ -25,6 +26,7 @@ public class TransactionHexArea extends CodeArea {
private static final int TRUNCATE_AT = 30000;
private static final int SEGMENTS_INTERVAL = 250;
private String fullHex;
private List<TransactionSegment> previousSegmentList = new ArrayList<>();
public TransactionHexArea() {
@ -37,7 +39,7 @@ public class TransactionHexArea extends CodeArea {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
transaction.bitcoinSerializeToStream(baos);
String fullHex = Utils.bytesToHex(baos.toByteArray());
fullHex = Utils.bytesToHex(baos.toByteArray());
String hex = fullHex;
if(hex.length() > TRUNCATE_AT) {
hex = hex.substring(0, TRUNCATE_AT);
@ -242,6 +244,18 @@ public class TransactionHexArea extends CodeArea {
};
}
@Override
public void copy() {
IndexRange selection = getSelection();
if(fullHex != null && selection.getLength() == getLength()) {
ClipboardContent content = new ClipboardContent();
content.putString(fullHex);
Clipboard.getSystemClipboard().setContent(content);
} else {
super.copy();
}
}
private static class TransactionSegment {
public TransactionSegment(int start, int length, Integer index, Integer witnessIndex, String style) {
this.start = start;

32
src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java

@ -7,6 +7,7 @@ import javafx.scene.control.SelectionMode;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableView;
import java.util.Comparator;
import java.util.List;
public class UtxosTreeTable extends CoinTreeTable {
@ -38,18 +39,25 @@ public class UtxosTreeTable extends CoinTreeTable {
});
getColumns().add(outputCol);
TreeTableColumn<Entry, Entry> addressCol = new TreeTableColumn<>("Address");
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
});
addressCol.setCellFactory(p -> new AddressCell());
addressCol.setSortable(true);
addressCol.setComparator((o1, o2) -> {
UtxoEntry entry1 = (UtxoEntry)o1;
UtxoEntry entry2 = (UtxoEntry)o2;
return entry1.getAddress().toString().compareTo(entry2.getAddress().toString());
});
getColumns().add(addressCol);
if(rootEntry.getWallet().isWhirlpoolMixWallet()) {
TreeTableColumn<Entry, UtxoEntry.MixStatus> mixStatusCol = new TreeTableColumn<>("Mixes");
mixStatusCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, UtxoEntry.MixStatus> param) -> {
return ((UtxoEntry)param.getValue().getValue()).mixStatusProperty();
});
mixStatusCol.setCellFactory(p -> new MixStatusCell());
mixStatusCol.setSortable(true);
mixStatusCol.setComparator(Comparator.comparingInt(UtxoEntry.MixStatus::getMixesDone));
getColumns().add(mixStatusCol);
} else {
TreeTableColumn<Entry, UtxoEntry.AddressStatus> addressCol = new TreeTableColumn<>("Address");
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, UtxoEntry.AddressStatus> param) -> {
return ((UtxoEntry)param.getValue().getValue()).addressStatusProperty();
});
addressCol.setCellFactory(p -> new AddressCell());
addressCol.setSortable(true);
addressCol.setComparator(Comparator.comparing(o -> o.getAddress().toString()));
getColumns().add(addressCol);
}
TreeTableColumn<Entry, String> labelCol = new TreeTableColumn<>("Label");
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {

2
src/main/java/com/sparrowwallet/sparrow/control/WalletImportDialog.java

@ -48,7 +48,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new SpecterDIY());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SpecterDIY());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);

25
src/main/java/com/sparrowwallet/sparrow/event/NewWalletTransactionsEvent.java

@ -6,21 +6,25 @@ import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.CoinLabel;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
public class NewWalletTransactionsEvent {
private final Wallet wallet;
private final List<BlockTransaction> blockTransactions;
private final List<TransactionEntry> transactionEntries;
private final long totalBlockchainValue;
private final long totalMempoolValue;
public NewWalletTransactionsEvent(Wallet wallet, List<BlockTransaction> blockTransactions, long totalBlockchainValue, long totalMempoolValue) {
public NewWalletTransactionsEvent(Wallet wallet, List<TransactionEntry> transactionEntries) {
this.wallet = wallet;
this.blockTransactions = blockTransactions;
this.totalBlockchainValue = totalBlockchainValue;
this.totalMempoolValue = totalMempoolValue;
this.transactionEntries = transactionEntries;
this.totalBlockchainValue = transactionEntries.stream().filter(txEntry -> txEntry.getConfirmations() > 0).mapToLong(Entry::getValue).sum();
this.totalMempoolValue = transactionEntries.stream().filter(txEntry ->txEntry.getConfirmations() == 0).mapToLong(Entry::getValue).sum();
}
public Wallet getWallet() {
@ -28,7 +32,7 @@ public class NewWalletTransactionsEvent {
}
public List<BlockTransaction> getBlockTransactions() {
return blockTransactions;
return transactionEntries.stream().map(TransactionEntry::getBlockTransaction).collect(Collectors.toList());
}
public long getTotalValue() {
@ -55,4 +59,13 @@ public class NewWalletTransactionsEvent {
return String.format(Locale.ENGLISH, "%,d", value) + " sats";
}
public List<BlockTransaction> getWhirlpoolMixTransactions() {
List<BlockTransaction> mixTransactions = new ArrayList<>();
if(wallet.isWhirlpoolMixWallet()) {
return transactionEntries.stream().filter(txEntry -> txEntry.getValue() == 0).map(TransactionEntry::getBlockTransaction).collect(Collectors.toList());
}
return mixTransactions;
}
}

3
src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java

@ -19,8 +19,7 @@ public class WalletNodeHistoryChangedEvent {
}
public WalletNode getWalletNode(Wallet wallet) {
List<KeyPurpose> keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
for(KeyPurpose keyPurpose : keyPurposes) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
WalletNode changedNode = getWalletNode(wallet, keyPurpose);
if(changedNode != null) {
return changedNode;

26
src/main/java/com/sparrowwallet/sparrow/event/WalletUtxoMixesChangedEvent.java

@ -0,0 +1,26 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.UtxoMixData;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.Map;
public class WalletUtxoMixesChangedEvent extends WalletChangedEvent {
private final Map<Sha256Hash, UtxoMixData> changedUtxoMixes;
private final Map<Sha256Hash, UtxoMixData> removedUtxoMixes;
public WalletUtxoMixesChangedEvent(Wallet wallet, Map<Sha256Hash, UtxoMixData> changedUtxoMixes, Map<Sha256Hash, UtxoMixData> removedUtxoMixes) {
super(wallet);
this.changedUtxoMixes = changedUtxoMixes;
this.removedUtxoMixes = removedUtxoMixes;
}
public Map<Sha256Hash, UtxoMixData> getChangedUtxoMixes() {
return changedUtxoMixes;
}
public Map<Sha256Hash, UtxoMixData> getRemovedUtxoMixes() {
return removedUtxoMixes;
}
}

59
src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixEvent.java

@ -0,0 +1,59 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WhirlpoolMixEvent {
private final Wallet wallet;
private final BlockTransactionHashIndex utxo;
private final MixProgress mixProgress;
private final Utxo nextUtxo;
private final MixFailReason mixFailReason;
public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, MixProgress mixProgress) {
this.wallet = wallet;
this.utxo = utxo;
this.mixProgress = mixProgress;
this.nextUtxo = null;
this.mixFailReason = null;
}
public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, Utxo nextUtxo) {
this.wallet = wallet;
this.utxo = utxo;
this.mixProgress = null;
this.nextUtxo = nextUtxo;
this.mixFailReason = null;
}
public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, MixFailReason mixFailReason) {
this.wallet = wallet;
this.utxo = utxo;
this.mixProgress = null;
this.nextUtxo = null;
this.mixFailReason = mixFailReason;
}
public Wallet getWallet() {
return wallet;
}
public BlockTransactionHashIndex getUtxo() {
return utxo;
}
public MixProgress getMixProgress() {
return mixProgress;
}
public Utxo getNextUtxo() {
return nextUtxo;
}
public MixFailReason getMixFailReason() {
return mixFailReason;
}
}

19
src/main/java/com/sparrowwallet/sparrow/event/WhirlpoolMixSuccessEvent.java

@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
public class WhirlpoolMixSuccessEvent extends WhirlpoolMixEvent {
private final WalletNode walletNode;
public WhirlpoolMixSuccessEvent(Wallet wallet, BlockTransactionHashIndex utxo, Utxo nextUtxo, WalletNode walletNode) {
super(wallet, utxo, nextUtxo);
this.walletNode = walletNode;
}
public WalletNode getWalletNode() {
return walletNode;
}
}

2
src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java

@ -44,7 +44,9 @@ public class FontAwesome5 extends GlyphFont {
LOCK_OPEN('\uf3c1'),
PEN_FANCY('\uf5ac'),
PLUS('\uf067'),
PLAY_CIRCLE('\uf144'),
PLUS_CIRCLE('\uf055'),
STOP_CIRCLE('\uf28d'),
QRCODE('\uf029'),
QUESTION_CIRCLE('\uf059'),
RANDOM('\uf074'),

2
src/main/java/com/sparrowwallet/sparrow/io/SeedSigner.java

@ -10,7 +10,7 @@ public class SeedSigner extends SpecterDIY {
@Override
public String getKeystoreImportDescription() {
return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports P2WSH Multisig wallets.";
return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports multisig wallets with a P2WSH script type.";
}
@Override

2
src/main/java/com/sparrowwallet/sparrow/io/SpecterDIY.java

@ -30,7 +30,7 @@ public class SpecterDIY implements KeystoreFileImport, WalletExport {
Keystore keystore = wallet.getKeystores().get(0);
keystore.setLabel(getName());
keystore.setWalletModel(WalletModel.SPECTER_DIY);
keystore.setWalletModel(getWalletModel());
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
return keystore;

25
src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java

@ -272,6 +272,19 @@ public class DbPersistence implements Persistence {
}
}
if(!dirtyPersistables.changedUtxoMixes.isEmpty()) {
UtxoMixDataDao utxoMixDataDao = handle.attach(UtxoMixDataDao.class);
for(Map.Entry<Sha256Hash, UtxoMixData> utxoMixDataEntry : dirtyPersistables.changedUtxoMixes.entrySet()) {
utxoMixDataDao.addOrUpdate(wallet, utxoMixDataEntry.getKey(), utxoMixDataEntry.getValue());
}
}
if(!dirtyPersistables.removedUtxoMixes.isEmpty()) {
UtxoMixDataDao utxoMixDataDao = handle.attach(UtxoMixDataDao.class);
List<Long> ids = dirtyPersistables.removedUtxoMixes.values().stream().map(Persistable::getId).filter(Objects::nonNull).collect(Collectors.toList());
utxoMixDataDao.deleteUtxoMixData(ids);
}
if(!dirtyPersistables.labelKeystores.isEmpty()) {
KeystoreDao keystoreDao = handle.attach(KeystoreDao.class);
for(Keystore keystore : dirtyPersistables.labelKeystores) {
@ -639,6 +652,14 @@ public class DbPersistence implements Persistence {
}
}
@Subscribe
public void walletUtxoMixesChanged(WalletUtxoMixesChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).changedUtxoMixes.putAll(event.getChangedUtxoMixes());
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).removedUtxoMixes.putAll(event.getRemovedUtxoMixes());
}
}
@Subscribe
public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) {
if(persistsFor(event.getWallet())) {
@ -659,6 +680,8 @@ public class DbPersistence implements Persistence {
public Integer blockHeight = null;
public final List<Entry> labelEntries = new ArrayList<>();
public final List<BlockTransactionHashIndex> utxoStatuses = new ArrayList<>();
public final Map<Sha256Hash, UtxoMixData> changedUtxoMixes = new HashMap<>();
public final Map<Sha256Hash, UtxoMixData> removedUtxoMixes = new HashMap<>();
public final List<Keystore> labelKeystores = new ArrayList<>();
public final List<Keystore> encryptionKeystores = new ArrayList<>();
@ -671,6 +694,8 @@ public class DbPersistence implements Persistence {
"\nAddress labels:" + labelEntries.stream().filter(entry -> entry instanceof NodeEntry).map(entry -> ((NodeEntry)entry).getNode().toString() + " " + entry.getLabel()).collect(Collectors.toList()) +
"\nUTXO labels:" + labelEntries.stream().filter(entry -> entry instanceof HashIndexEntry).map(entry -> ((HashIndexEntry)entry).getHashIndex().toString()).collect(Collectors.toList()) +
"\nUTXO statuses:" + utxoStatuses +
"\nUTXO mixes changed:" + changedUtxoMixes +
"\nUTXO mixes removed:" + removedUtxoMixes +
"\nKeystore labels:" + labelKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) +
"\nKeystore encryptions:" + encryptionKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList());
}

56
src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataDao.java

@ -0,0 +1,56 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.UtxoMixData;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.BindList;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.List;
import java.util.Map;
public interface UtxoMixDataDao {
@SqlQuery("select id, hash, poolId, mixesDone, forwarding from utxoMixData where wallet = ? order by id")
@RegisterRowMapper(UtxoMixDataMapper.class)
Map<Sha256Hash, UtxoMixData> getForWalletId(Long id);
@SqlQuery("select id, hash, poolId, mixesDone, forwarding from utxoMixData where hash = ?")
@RegisterRowMapper(UtxoMixDataMapper.class)
Map<Sha256Hash, UtxoMixData> getForHash(byte[] hash);
@SqlUpdate("insert into utxoMixData (hash, poolId, mixesDone, forwarding, wallet) values (?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertUtxoMixData(byte[] hash, String poolId, int mixesDone, Long forwarding, long wallet);
@SqlUpdate("update utxoMixData set hash = ?, poolId = ?, mixesDone = ?, forwarding = ?, wallet = ? where id = ?")
void updateUtxoMixData(byte[] hash, String poolId, int mixesDone, Long forwarding, long wallet, long id);
@SqlUpdate("delete from utxoMixData where id in (<ids>)")
void deleteUtxoMixData(@BindList("ids") List<Long> ids);
@SqlUpdate("delete from utxoMixData where wallet = ?")
void clear(long wallet);
default void addUtxoMixData(Wallet wallet) {
for(Map.Entry<Sha256Hash, UtxoMixData> utxoMixDataEntry : wallet.getUtxoMixes().entrySet()) {
utxoMixDataEntry.getValue().setId(null);
addOrUpdate(wallet, utxoMixDataEntry.getKey(), utxoMixDataEntry.getValue());
}
}
default void addOrUpdate(Wallet wallet, Sha256Hash hash, UtxoMixData utxoMixData) {
Map<Sha256Hash, UtxoMixData> existing = getForHash(hash.getBytes());
if(existing.isEmpty() && utxoMixData.getId() == null) {
long id = insertUtxoMixData(hash.getBytes(), utxoMixData.getPoolId(), utxoMixData.getMixesDone(), utxoMixData.getForwarding(), wallet.getId());
utxoMixData.setId(id);
} else {
Long existingId = existing.get(hash) != null ? existing.get(hash).getId() : utxoMixData.getId();
updateUtxoMixData(hash.getBytes(), utxoMixData.getPoolId(), utxoMixData.getMixesDone(), utxoMixData.getForwarding(), wallet.getId(), existingId);
utxoMixData.setId(existingId);
}
}
}

42
src/main/java/com/sparrowwallet/sparrow/io/db/UtxoMixDataMapper.java

@ -0,0 +1,42 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.UtxoMixData;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
public class UtxoMixDataMapper implements RowMapper<Map.Entry<Sha256Hash, UtxoMixData>> {
@Override
public Map.Entry<Sha256Hash, UtxoMixData> map(ResultSet rs, StatementContext ctx) throws SQLException {
Sha256Hash hash = Sha256Hash.wrap(rs.getBytes("hash"));
Long forwarding = rs.getLong("forwarding");
if(rs.wasNull()) {
forwarding = null;
}
UtxoMixData utxoMixData = new UtxoMixData(rs.getString("poolId"), rs.getInt("mixesDone"), forwarding);
utxoMixData.setId(rs.getLong("id"));
return new Map.Entry<>() {
@Override
public Sha256Hash getKey() {
return hash;
}
@Override
public UtxoMixData getValue() {
return utxoMixData;
}
@Override
public UtxoMixData setValue(UtxoMixData value) {
return null;
}
};
}
}

8
src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java

@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.UtxoMixData;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.sqlobject.CreateSqlObject;
@ -29,6 +30,9 @@ public interface WalletDao {
@CreateSqlObject
BlockTransactionDao createBlockTransactionDao();
@CreateSqlObject
UtxoMixDataDao createUtxoMixDataDao();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadAllWallets();
@ -86,6 +90,9 @@ public interface WalletDao {
Map<Sha256Hash, BlockTransaction> blockTransactions = createBlockTransactionDao().getForWalletId(wallet.getId()); //.stream().collect(Collectors.toMap(BlockTransaction::getHash, Function.identity(), (existing, replacement) -> existing, LinkedHashMap::new));
wallet.updateTransactions(blockTransactions);
Map<Sha256Hash, UtxoMixData> utxoMixes = createUtxoMixDataDao().getForWalletId(wallet.getId());
wallet.getUtxoMixes().putAll(utxoMixes);
}
default void addWallet(String schema, Wallet wallet) {
@ -99,6 +106,7 @@ public interface WalletDao {
createKeystoreDao().addKeystores(wallet);
createWalletNodeDao().addWalletNodes(wallet);
createBlockTransactionDao().addBlockTransactions(wallet);
createUtxoMixDataDao().addUtxoMixData(wallet);
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}

18
src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java

@ -700,6 +700,21 @@ public class ElectrumServer {
if(!transactionOutputs.equals(node.getTransactionOutputs())) {
node.updateTransactionOutputs(transactionOutputs);
copyPostmixLabels(wallet, transactionOutputs);
}
}
public void copyPostmixLabels(Wallet wallet, Set<BlockTransactionHashIndex> newTransactionOutputs) {
if(wallet.getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX) {
for(BlockTransactionHashIndex newRef : newTransactionOutputs) {
BlockTransactionHashIndex prevRef = wallet.getWalletTxos().keySet().stream()
.filter(txo -> wallet.getMasterWallet().getUtxoMixData(txo) != null && txo.isSpent() && txo.getSpentBy().getHash().equals(newRef.getHash())).findFirst().orElse(null);
if(prevRef != null && wallet.getMasterWallet().getUtxoMixData(newRef) != null) {
if(newRef.getLabel() == null && prevRef.getLabel() != null) {
newRef.setLabel(prevRef.getLabel());
}
}
}
}
}
@ -828,8 +843,7 @@ public class ElectrumServer {
public static Map<String, WalletNode> getAllScriptHashes(Wallet wallet) {
Map<String, WalletNode> scriptHashes = new HashMap<>();
List<KeyPurpose> purposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
for(KeyPurpose keyPurpose : purposes) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
for(WalletNode childNode : wallet.getNode(keyPurpose).getChildren()) {
scriptHashes.put(getScriptHash(wallet, childNode), childNode);
}

2
src/main/java/com/sparrowwallet/sparrow/net/IpAddressMatcher.java

@ -79,8 +79,6 @@ public final class IpAddressMatcher {
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;

2
src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java

@ -859,7 +859,7 @@ public class HeadersController extends TransactionFormController implements Init
broadcastTransactionService.setOnFailed(workerStateEvent -> {
broadcastProgressBar.setProgress(0);
log.error("Error broadcasting transaction", workerStateEvent.getSource().getException());
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error when broadcasting the transaction. The server response is contained in sparrow.log");
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error when broadcasting the transaction. The server response is contained in the log (See Help > Show Log File).");
broadcastButton.setDisable(false);
});

3
src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java

@ -115,6 +115,7 @@ public class AddressesController extends WalletFormController implements Initial
fileChooser.setTitle("Export Addresses to CSV");
fileChooser.setInitialFileName(getWalletForm().getWallet().getFullName() + "-" + keyPurpose.name().toLowerCase() + "-addresses.csv");
boolean whirlpoolMixWallet = getWalletForm().getWallet().isWhirlpoolMixWallet();
Wallet copy = getWalletForm().getWallet().copy();
WalletNode purposeNode = copy.getNode(keyPurpose);
purposeNode.fillToIndex(Math.max(purposeNode.getChildren().size(), DEFAULT_EXPORT_ADDRESSES_LENGTH));
@ -127,7 +128,7 @@ public class AddressesController extends WalletFormController implements Initial
writer.writeRecord(new String[] {"Index", "Payment Address", "Derivation", "Label"});
for(WalletNode indexNode : purposeNode.getChildren()) {
writer.write(Integer.toString(indexNode.getIndex()));
writer.write(copy.getAddress(indexNode).toString());
writer.write(whirlpoolMixWallet ? copy.getAddress(indexNode).toString().substring(0, 20) + "..." : copy.getAddress(indexNode).toString());
writer.write(getDerivationPath(indexNode));
Optional<Entry> optLabelEntry = getWalletForm().getNodeEntry(keyPurpose).getChildren().stream()
.filter(entry -> ((NodeEntry)entry).getNode().getIndex() == indexNode.getIndex()).findFirst();

11
src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java

@ -25,6 +25,7 @@ import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ListChangeListener;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@ -199,6 +200,10 @@ public class SendController extends WalletFormController implements Initializabl
}
};
private final ChangeListener<Boolean> premixButtonOnlineListener = (observable, oldValue, newValue) -> {
premixButton.setDisable(!newValue);
};
private ValidationSupport validationSupport;
private WalletTransactionService walletTransactionService;
@ -385,9 +390,7 @@ public class SendController extends WalletFormController implements Initializabl
premixButton.managedProperty().bind(premixButton.visibleProperty());
createButton.visibleProperty().bind(premixButton.visibleProperty().not());
premixButton.setVisible(false);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
premixButton.setDisable(!newValue);
});
AppServices.onlineProperty().addListener(new WeakChangeListener<>(premixButtonOnlineListener));
}
private void initializeTabHeader(int count) {
@ -1054,7 +1057,7 @@ public class SendController extends WalletFormController implements Initializabl
public void broadcastPremixUnencrypted(Wallet decryptedWallet) {
Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWalletId());
whirlpool.setScode(Config.get().getScode());
whirlpool.setHDWallet(decryptedWallet);
whirlpool.setHDWallet(getWalletForm().getWalletId(), decryptedWallet);
Map<BlockTransactionHashIndex, WalletNode> utxos = walletTransactionProperty.get().getSelectedUtxos();
Whirlpool.Tx0BroadcastService tx0BroadcastService = new Whirlpool.Tx0BroadcastService(whirlpool, whirlpoolProperty.get(), utxos.keySet());
tx0BroadcastService.setOnRunning(workerStateEvent -> {

148
src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java

@ -1,11 +1,15 @@
package com.sparrowwallet.sparrow.wallet;
import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.mix.listener.MixStep;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.BooleanPropertyBase;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@ -32,6 +36,10 @@ public class UtxoEntry extends HashIndexEntry {
return false;
}
public boolean isMixing() {
return mixStatusProperty != null && ((mixStatusProperty.get().getMixProgress() != null && mixStatusProperty.get().getMixProgress().getMixStep() != MixStep.FAIL) || mixStatusProperty.get().getNextMixUtxo() != null);
}
public Address getAddress() {
return getWallet().getAddress(node);
}
@ -47,33 +55,129 @@ public class UtxoEntry extends HashIndexEntry {
/**
* Defines whether this utxo shares it's address with another utxo in the wallet
*/
private BooleanProperty duplicateAddress;
private ObjectProperty<AddressStatus> addressStatusProperty;
public final void setDuplicateAddress(boolean value) {
if(duplicateAddress != null || value) {
duplicateAddressProperty().set(value);
}
addressStatusProperty().set(new AddressStatus(value));
}
public final boolean isDuplicateAddress() {
return duplicateAddress != null && duplicateAddress.get();
return addressStatusProperty != null && addressStatusProperty.get().isDuplicate();
}
public final BooleanProperty duplicateAddressProperty() {
if(duplicateAddress == null) {
duplicateAddress = new BooleanPropertyBase(false) {
public final ObjectProperty<AddressStatus> addressStatusProperty() {
if(addressStatusProperty == null) {
addressStatusProperty = new SimpleObjectProperty<>(UtxoEntry.this, "addressStatus", new AddressStatus(false));
}
@Override
public Object getBean() {
return UtxoEntry.this;
}
return addressStatusProperty;
}
public class AddressStatus {
private final boolean duplicate;
public AddressStatus(boolean duplicate) {
this.duplicate = duplicate;
}
public UtxoEntry getUtxoEntry() {
return UtxoEntry.this;
}
public Address getAddress() {
return UtxoEntry.this.getAddress();
}
public boolean isDuplicate() {
return duplicate;
}
}
/**
* Contains the mix status of this utxo, if available
*/
private ObjectProperty<MixStatus> mixStatusProperty;
public void setMixProgress(MixProgress mixProgress) {
mixStatusProperty().set(new MixStatus(mixProgress));
}
public void setMixFailReason(MixFailReason mixFailReason) {
mixStatusProperty().set(new MixStatus(mixFailReason));
}
public void setNextMixUtxo(Utxo nextMixUtxo) {
mixStatusProperty().set(new MixStatus(nextMixUtxo));
}
public final MixStatus getMixStatus() {
return mixStatusProperty == null ? null : mixStatusProperty.get();
}
public final ObjectProperty<MixStatus> mixStatusProperty() {
if(mixStatusProperty == null) {
mixStatusProperty = new SimpleObjectProperty<>(UtxoEntry.this, "mixStatus", null);
}
return mixStatusProperty;
}
public class MixStatus {
private MixProgress mixProgress;
private Utxo nextMixUtxo;
private MixFailReason mixFailReason;
public MixStatus(MixProgress mixProgress) {
this.mixProgress = mixProgress;
}
public MixStatus(Utxo nextMixUtxo) {
this.nextMixUtxo = nextMixUtxo;
}
public MixStatus(MixFailReason mixFailReason) {
this.mixFailReason = mixFailReason;
}
public UtxoEntry getUtxoEntry() {
return UtxoEntry.this;
}
public UtxoMixData getUtxoMixData() {
Wallet wallet = getUtxoEntry().getWallet().getMasterWallet();
if(wallet.getUtxoMixData(getHashIndex()) != null) {
return wallet.getUtxoMixData(getHashIndex());
}
@Override
public String getName() {
return "duplicate";
Whirlpool whirlpool = AppServices.get().getWhirlpool(wallet);
if(whirlpool != null) {
UtxoMixData utxoMixData = whirlpool.getMixData(getHashIndex());
if(utxoMixData != null) {
return utxoMixData;
}
};
}
return new UtxoMixData("Unknown Pool", getUtxoEntry().getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX ? 1 : 0, null);
}
public int getMixesDone() {
return getUtxoMixData().getMixesDone();
}
public String getPoolId() {
return getUtxoMixData().getPoolId();
}
public MixProgress getMixProgress() {
return mixProgress;
}
public Utxo getNextMixUtxo() {
return nextMixUtxo;
}
public MixFailReason getMixFailReason() {
return mixFailReason;
}
return duplicateAddress;
}
}

105
src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java

@ -15,14 +15,18 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolDialog;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
@ -42,6 +46,15 @@ public class UtxosController extends WalletFormController implements Initializab
@FXML
private UtxosTreeTable utxosTable;
@FXML
private HBox mixButtonsBox;
@FXML
private Button startMix;
@FXML
private Button stopMix;
@FXML
private Button sendSelected;
@ -51,6 +64,12 @@ public class UtxosController extends WalletFormController implements Initializab
@FXML
private UtxosChart utxosChart;
private final ChangeListener<Boolean> mixingOnlineListener = (observable, oldValue, newValue) -> {
mixSelected.setDisable(getSelectedEntries().isEmpty() || !newValue);
startMix.setDisable(!newValue);
stopMix.setDisable(!newValue);
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@ -60,14 +79,30 @@ public class UtxosController extends WalletFormController implements Initializab
public void initializeView() {
utxosTable.initialize(getWalletForm().getWalletUtxosEntry());
utxosChart.initialize(getWalletForm().getWalletUtxosEntry());
mixButtonsBox.managedProperty().bind(mixButtonsBox.visibleProperty());
mixButtonsBox.setVisible(getWalletForm().getWallet().isWhirlpoolMixWallet());
startMix.managedProperty().bind(startMix.visibleProperty());
startMix.setDisable(!AppServices.isConnected());
stopMix.managedProperty().bind(stopMix.visibleProperty());
startMix.visibleProperty().bind(stopMix.visibleProperty().not());
stopMix.visibleProperty().addListener((observable, oldValue, newValue) -> {
stopMix.setDisable(!newValue);
startMix.setDisable(newValue);
});
if(mixButtonsBox.isVisible()) {
Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet());
if(whirlpool != null) {
stopMix.visibleProperty().bind(whirlpool.mixingProperty());
}
}
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);
});
AppServices.onlineProperty().addListener(new WeakChangeListener<>(mixingOnlineListener));
utxosTable.getSelectionModel().getSelectedIndices().addListener((ListChangeListener<Integer>) c -> {
List<Entry> selectedEntries = utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue()).collect(Collectors.toList());
@ -76,11 +111,11 @@ public class UtxosController extends WalletFormController implements Initializab
});
utxosChart.managedProperty().bind(utxosChart.visibleProperty());
utxosChart.setVisible(Config.get().isShowUtxosChart());
utxosChart.setVisible(Config.get().isShowUtxosChart() && !getWalletForm().getWallet().isWhirlpoolMixWallet());
}
private boolean canWalletMix() {
return Network.get() == Network.TESTNET && getWalletForm().getWallet().getKeystores().size() == 1 && getWalletForm().getWallet().getKeystores().get(0).hasSeed();
return Network.get() == Network.TESTNET && getWalletForm().getWallet().getKeystores().size() == 1 && getWalletForm().getWallet().getKeystores().get(0).hasSeed() && !getWalletForm().getWallet().isWhirlpoolMixWallet();
}
private void updateButtons(BitcoinUnit unit) {
@ -104,13 +139,13 @@ public class UtxosController extends WalletFormController implements Initializab
}
} else {
sendSelected.setText("Send Selected");
sendSelected.setText("Mix Selected");
mixSelected.setText("Mix Selected");
}
}
private List<Entry> getSelectedEntries() {
return utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> tp.getTreeItem().getValue())
.filter(entry -> ((HashIndexEntry)entry).isSpendable())
return utxosTable.getSelectionModel().getSelectedCells().stream().map(tp -> (UtxoEntry)tp.getTreeItem().getValue())
.filter(utxoEntry -> utxoEntry.isSpendable() && !utxoEntry.isMixing())
.collect(Collectors.toList());
}
@ -180,6 +215,39 @@ public class UtxosController extends WalletFormController implements Initializab
utxosTable.getSelectionModel().clearSelection();
}
public void startMixing(ActionEvent event) {
startMix.setDisable(true);
stopMix.setDisable(false);
Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet());
if(whirlpool != null && !whirlpool.isStarted() && AppServices.isConnected()) {
Whirlpool.StartupService startupService = new Whirlpool.StartupService(whirlpool);
startupService.setOnFailed(workerStateEvent -> {
AppServices.showErrorDialog("Failed to start whirlpool", workerStateEvent.getSource().getException().getMessage());
log.error("Failed to start whirlpool", workerStateEvent.getSource().getException());
});
startupService.start();
}
}
public void stopMixing(ActionEvent event) {
stopMix.setDisable(true);
startMix.setDisable(false);
Whirlpool whirlpool = AppServices.get().getWhirlpool(getWalletForm().getWallet());
if(whirlpool.isStarted()) {
Whirlpool.ShutdownService shutdownService = new Whirlpool.ShutdownService(whirlpool);
shutdownService.setOnFailed(workerStateEvent -> {
log.error("Failed to stop whirlpool", workerStateEvent.getSource().getException());
AppServices.showErrorDialog("Failed to stop whirlpool", workerStateEvent.getSource().getException().getMessage());
});
shutdownService.start();
} else {
//Ensure http clients are shutdown
whirlpool.shutdown();
}
}
public void exportUtxos(ActionEvent event) {
Stage window = new Stage();
@ -297,6 +365,25 @@ public class UtxosController extends WalletFormController implements Initializab
@Subscribe
public void utxosChartChanged(UtxosChartChangedEvent event) {
utxosChart.setVisible(event.isVisible());
utxosChart.setVisible(event.isVisible() && !getWalletForm().getWallet().isWhirlpoolMixWallet());
}
@Subscribe
public void whirlpoolMix(WhirlpoolMixEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
WalletUtxosEntry walletUtxosEntry = getWalletForm().getWalletUtxosEntry();
for(Entry entry : walletUtxosEntry.getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().equals(event.getUtxo())) {
if(event.getNextUtxo() != null) {
utxoEntry.setNextMixUtxo(event.getNextUtxo());
} else if(event.getMixFailReason() != null) {
utxoEntry.setMixFailReason(event.getMixFailReason());
} else {
utxoEntry.setMixProgress(event.getMixProgress());
}
}
}
}
}
}

12
src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java

@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ReceiveActionEvent;
@ -72,10 +73,13 @@ public class WalletController extends WalletFormController implements Initializa
}
});
configure(walletForm.getWallet().isValid());
configure(walletForm.getWallet());
}
public void configure(boolean validWallet) {
public void configure(Wallet wallet) {
boolean validWallet = wallet.isValid();
boolean whirlpoolMixWallet = wallet.isWhirlpoolMixWallet();
for(Toggle toggle : walletMenu.getToggles()) {
if(toggle.getUserData().equals(Function.SETTINGS)) {
if(!validWallet) {
@ -86,7 +90,7 @@ public class WalletController extends WalletFormController implements Initializa
toggle.setSelected(true);
}
((ToggleButton)toggle).setDisable(!validWallet);
((ToggleButton)toggle).setDisable(!validWallet || (whirlpoolMixWallet && toggle.getUserData().equals(Function.RECEIVE)));
}
}
}
@ -104,7 +108,7 @@ public class WalletController extends WalletFormController implements Initializa
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
if(event.getWalletId().equals(walletForm.getWalletId())) {
configure(event.getWallet().isValid());
configure(event.getWallet());
}
}

61
src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java

@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
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.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
@ -17,6 +14,7 @@ import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
import javafx.application.Platform;
import javafx.util.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -37,6 +35,8 @@ public class WalletForm {
private final List<NodeEntry> accountEntries = new ArrayList<>();
private final List<Set<WalletNode>> walletTransactionNodes = new ArrayList<>();
private ElectrumServer.TransactionMempoolService transactionMempoolService;
public WalletForm(Storage storage, Wallet currentWallet, Wallet backupWallet) {
this(storage, currentWallet, backupWallet, true);
}
@ -146,6 +146,7 @@ public class WalletForm {
Set<Entry> labelChangedEntries = Collections.emptySet();
if(pastWallet != null) {
labelChangedEntries = copyLabels(pastWallet);
copyMixData(pastWallet);
}
notifyIfChanged(blockHeight, previousWallet, labelChangedEntries);
@ -156,14 +157,13 @@ public class WalletForm {
//On a full wallet refresh, walletUtxosEntry and walletTransactionsEntry will have no children yet, but AddressesController may have created accountEntries on a walletNodesChangedEvent
//Copy nodeEntry labels
List<KeyPurpose> keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
for(KeyPurpose keyPurpose : keyPurposes) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
NodeEntry purposeEntry = getNodeEntry(keyPurpose);
changedEntries.addAll(purposeEntry.copyLabels(pastWallet.getNode(purposeEntry.getNode().getKeyPurpose())));
}
//Copy node and txo labels
for(KeyPurpose keyPurpose : keyPurposes) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
if(wallet.getNode(keyPurpose).copyLabels(pastWallet.getNode(keyPurpose))) {
changedEntries.add(getWalletUtxosEntry());
}
@ -182,6 +182,10 @@ public class WalletForm {
return changedEntries;
}
private void copyMixData(Wallet pastWallet) {
wallet.getUtxoMixes().forEach(pastWallet.getUtxoMixes()::putIfAbsent);
}
private void notifyIfChanged(Integer blockHeight, Wallet previousWallet, Set<Entry> labelChangedEntries) {
List<WalletNode> historyChangedNodes = new ArrayList<>();
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), wallet.getNode(KeyPurpose.RECEIVE).getChildren()));
@ -361,6 +365,10 @@ public class WalletForm {
@Subscribe
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
if(wallet.isValid()) {
if(transactionMempoolService != null) {
transactionMempoolService.cancel();
}
WalletNode walletNode = event.getWalletNode(wallet);
if(walletNode != null) {
log.debug(wallet.getFullName() + " history event for node " + walletNode + " (" + event.getScriptHash() + ")");
@ -382,7 +390,7 @@ public class WalletForm {
changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()));
}
if(receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) {
if((receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) && wallet.getStandardAccountType() != StandardAccount.WHIRLPOOL_PREMIX) {
receivedRef.setLabel(changedNode.getLabel() + (changedNode.getKeyPurpose() == KeyPurpose.CHANGE ? " (change)" : " (received)"));
changedLabelEntries.add(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, changedNode.getKeyPurpose()));
}
@ -404,12 +412,11 @@ public class WalletForm {
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
if(entry instanceof TransactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
List<KeyPurpose> keyPurposes = List.of(KeyPurpose.RECEIVE, KeyPurpose.CHANGE);
for(KeyPurpose keyPurpose : keyPurposes) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
for(WalletNode childNode : wallet.getNode(keyPurpose).getChildren()) {
for(BlockTransactionHashIndex receivedRef : childNode.getTransactionOutputs()) {
if(receivedRef.getHash().equals(transactionEntry.getBlockTransaction().getHash())) {
if(receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) {
if((receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) && wallet.getStandardAccountType() != StandardAccount.WHIRLPOOL_PREMIX) {
receivedRef.setLabel(entry.getLabel() + (keyPurpose == KeyPurpose.CHANGE ? " (change)" : " (received)"));
labelChangedEntries.add(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, keyPurpose));
}
@ -462,6 +469,38 @@ public class WalletForm {
}
}
@Subscribe
public void walletUtxoMixesChanged(WalletUtxoMixesChangedEvent event) {
if(event.getWallet() == wallet) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
}
}
@Subscribe
public void whirlpoolMixSuccess(WhirlpoolMixSuccessEvent event) {
if(event.getWallet() == wallet && event.getWalletNode() != null) {
if(transactionMempoolService != null) {
transactionMempoolService.cancel();
}
transactionMempoolService = new ElectrumServer.TransactionMempoolService(event.getWallet(), Sha256Hash.wrap(event.getNextUtxo().getHash()), Set.of(event.getWalletNode()));
transactionMempoolService.setDelay(Duration.seconds(5));
transactionMempoolService.setPeriod(Duration.seconds(5));
transactionMempoolService.setRestartOnFailure(false);
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
Set<String> scriptHashes = transactionMempoolService.getValue();
if(!scriptHashes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHashes.iterator().next())));
}
if(transactionMempoolService.getIterationCount() > 10) {
transactionMempoolService.cancel();
}
});
transactionMempoolService.start();
}
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {

5
src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java

@ -69,10 +69,7 @@ public class WalletTransactionsEntry extends Entry {
List<Entry> entriesComplete = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).isComplete()).collect(Collectors.toList());
if(!entriesComplete.isEmpty()) {
List<BlockTransaction> blockTransactions = entriesAdded.stream().map(txEntry -> ((TransactionEntry)txEntry).getBlockTransaction()).collect(Collectors.toList());
long totalBlockchainValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() > 0).mapToLong(Entry::getValue).sum();
long totalMempoolValue = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).getConfirmations() == 0).mapToLong(Entry::getValue).sum();
EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), blockTransactions, totalBlockchainValue, totalMempoolValue));
EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), entriesAdded.stream().map(entry -> (TransactionEntry)entry).collect(Collectors.toList())));
}
if(entriesAdded.size() > entriesComplete.size()) {

16
src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java

@ -1,6 +1,9 @@
package com.sparrowwallet.sparrow.wallet;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import java.util.*;
import java.util.stream.Collectors;
@ -9,6 +12,7 @@ public class WalletUtxosEntry extends Entry {
public WalletUtxosEntry(Wallet wallet) {
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
calculateDuplicates();
retrieveMixProgress();
}
@Override
@ -34,6 +38,17 @@ public class WalletUtxosEntry extends Entry {
}
}
protected void retrieveMixProgress() {
Whirlpool whirlpool = AppServices.get().getWhirlpool(getWallet());
if(whirlpool != null) {
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
MixProgress mixProgress = whirlpool.getMixProgress(utxoEntry.getHashIndex());
utxoEntry.setMixProgress(mixProgress);
}
}
}
public void updateUtxos() {
List<Entry> current = getWallet().getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList());
List<Entry> previous = new ArrayList<>(getChildren());
@ -47,5 +62,6 @@ public class WalletUtxosEntry extends Entry {
getChildren().removeAll(entriesRemoved);
calculateDuplicates();
retrieveMixProgress();
}
}

4
src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowDataSource.java

@ -226,12 +226,12 @@ public class SparrowDataSource extends WalletResponseDataSource {
private Wallet getWallet(String zpub) {
return AppServices.get().getOpenWallets().keySet().stream()
.filter(Wallet::isValid)
.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);
return extPubKey.toString(header).equals(zpub);
})
.findFirst()
.orElse(null);

77
src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowUtxoConfigPersister.java

@ -0,0 +1,77 @@
package com.sparrowwallet.sparrow.whirlpool;
import com.google.common.collect.MapDifference;
import com.google.common.collect.Maps;
import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigData;
import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersisted;
import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersister;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.UtxoMixData;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletUtxoMixesChangedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.stream.Collectors;
public class SparrowUtxoConfigPersister extends UtxoConfigPersister {
private static final Logger log = LoggerFactory.getLogger(SparrowUtxoConfigPersister.class);
private final String walletId;
private long lastWrite;
public SparrowUtxoConfigPersister(String walletId) {
super(walletId);
this.walletId = walletId;
}
@Override
public synchronized UtxoConfigData load() throws Exception {
Wallet wallet = getWallet();
if(wallet == null) {
throw new IllegalStateException("Can't find wallet with walletId " + walletId);
}
Map<String, UtxoConfigPersisted> utxoConfigs = wallet.getUtxoMixes().entrySet().stream()
.collect(Collectors.toMap(entry -> entry.getKey().toString(), entry -> new UtxoConfigPersisted(entry.getValue().getPoolId(), entry.getValue().getMixesDone(), entry.getValue().getForwarding()),
(u, v) -> { throw new IllegalStateException("Duplicate utxo config hashes"); },
HashMap::new));
return new UtxoConfigData(utxoConfigs);
}
@Override
public synchronized void write(UtxoConfigData data) throws Exception {
Wallet wallet = getWallet();
if(wallet == null) {
//Wallet is already closed
return;
}
Map<String, UtxoConfigPersisted> currentData = new HashMap<>(data.getUtxoConfigs());
Map<Sha256Hash, UtxoMixData> changedUtxoMixes = currentData.entrySet().stream()
.collect(Collectors.toMap(entry -> Sha256Hash.wrap(entry.getKey()), entry -> new UtxoMixData(entry.getValue().getPoolId(), entry.getValue().getMixsDone(), entry.getValue().getForwarding()),
(u, v) -> { throw new IllegalStateException("Duplicate utxo config hashes"); },
HashMap::new));
MapDifference<Sha256Hash, UtxoMixData> mapDifference = Maps.difference(changedUtxoMixes, wallet.getUtxoMixes());
Map<Sha256Hash, UtxoMixData> removedUtxoMixes = mapDifference.entriesOnlyOnRight();
wallet.getUtxoMixes().putAll(changedUtxoMixes);
wallet.getUtxoMixes().keySet().removeAll(removedUtxoMixes.keySet());
EventManager.get().post(new WalletUtxoMixesChangedEvent(wallet, changedUtxoMixes, removedUtxoMixes));
lastWrite = System.currentTimeMillis();
}
private Wallet getWallet() {
return AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElse(null);
}
@Override
public long getLastWrite() {
return lastWrite;
}
}

24
src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletDataSupplier.java

@ -0,0 +1,24 @@
package com.sparrowwallet.sparrow.whirlpool;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.whirlpool.client.wallet.WhirlpoolWalletConfig;
import com.samourai.whirlpool.client.wallet.data.minerFee.BackendWalletDataSupplier;
import com.samourai.whirlpool.client.wallet.data.minerFee.WalletSupplier;
import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersister;
public class SparrowWalletDataSupplier extends BackendWalletDataSupplier {
public SparrowWalletDataSupplier(int refreshUtxoDelay, WhirlpoolWalletConfig config, HD_Wallet bip44w, String walletIdentifier) throws Exception {
super(refreshUtxoDelay, config, bip44w, walletIdentifier);
}
@Override
protected WalletSupplier computeWalletSupplier(WhirlpoolWalletConfig config, HD_Wallet bip44w, String walletIdentifier) throws Exception {
int externalIndexDefault = config.getExternalDestination() != null ? config.getExternalDestination().getStartIndex() : 0;
return new WalletSupplier(new SparrowWalletStatePersister(walletIdentifier), config.getBackendApi(), bip44w, externalIndexDefault);
}
@Override
protected UtxoConfigPersister computeUtxoConfigPersister(String walletIdentifier) throws Exception {
return new SparrowUtxoConfigPersister(walletIdentifier);
}
}

53
src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWalletStatePersister.java

@ -0,0 +1,53 @@
package com.sparrowwallet.sparrow.whirlpool;
import com.samourai.whirlpool.client.wallet.data.walletState.WalletStateData;
import com.samourai.whirlpool.client.wallet.data.walletState.WalletStatePersister;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import java.util.LinkedHashMap;
import java.util.Map;
public class SparrowWalletStatePersister extends WalletStatePersister {
private final String walletId;
public SparrowWalletStatePersister(String walletId) {
super(walletId);
this.walletId = walletId;
}
@Override
public synchronized WalletStateData load() throws Exception {
Wallet wallet = AppServices.get().getOpenWallets().entrySet().stream().filter(entry -> entry.getValue().getWalletId(entry.getKey()).equals(walletId)).map(Map.Entry::getKey).findFirst().orElseThrow();
Map<String, Integer> values = new LinkedHashMap<>();
values.put("init", 1);
putValues("DEPOSIT", wallet, values);
for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) {
putValues(whirlpoolAccount.getName().toUpperCase(), wallet.getChildWallet(whirlpoolAccount), values);
}
return new WalletStateData(values);
}
private void putValues(String prefix, Wallet wallet, Map<String, Integer> values) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
Integer index = wallet.getNode(keyPurpose).getHighestUsedIndex();
values.put(prefix + "_" + getPurpose(wallet) + "_" + keyPurpose.getPathIndex().num(), index == null ? 0 : index + 1);
}
}
private int getPurpose(Wallet wallet) {
ScriptType scriptType = wallet.getScriptType();
return scriptType.getDefaultDerivation().get(0).num();
}
@Override
public synchronized void write(WalletStateData data) throws Exception {
//nothing required
}
}

23
src/main/java/com/sparrowwallet/sparrow/whirlpool/SparrowWhirlpoolWalletService.java

@ -0,0 +1,23 @@
package com.sparrowwallet.sparrow.whirlpool;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.whirlpool.client.wallet.WhirlpoolWalletConfig;
import com.samourai.whirlpool.client.wallet.WhirlpoolWalletService;
import com.samourai.whirlpool.client.wallet.data.minerFee.WalletDataSupplier;
public class SparrowWhirlpoolWalletService extends WhirlpoolWalletService {
private String walletId;
@Override
protected WalletDataSupplier computeWalletDataSupplier(WhirlpoolWalletConfig config, HD_Wallet bip44w, String walletIdentifier) throws Exception {
return new SparrowWalletDataSupplier(config.getRefreshUtxoDelay(), config, bip44w, walletId);
}
public String getWalletId() {
return walletId;
}
public void setWalletId(String walletId) {
this.walletId = walletId;
}
}

183
src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java

@ -14,19 +14,17 @@ 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.Tx0FeeTarget;
import com.samourai.whirlpool.client.wallet.beans.WhirlpoolAccount;
import com.samourai.whirlpool.client.wallet.beans.WhirlpoolServer;
import com.samourai.whirlpool.client.wallet.beans.WhirlpoolUtxo;
import com.samourai.whirlpool.client.wallet.beans.*;
import com.samourai.whirlpool.client.wallet.data.dataPersister.DataPersisterFactory;
import com.samourai.whirlpool.client.wallet.data.dataPersister.FileDataPersister;
import com.samourai.whirlpool.client.wallet.data.dataSource.DataSourceFactory;
import com.samourai.whirlpool.client.wallet.data.pool.PoolData;
import com.samourai.whirlpool.client.wallet.data.utxo.UtxoConfigPersisted;
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.KeyPurpose;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.ScriptType;
@ -38,7 +36,13 @@ 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.EventManager;
import com.sparrowwallet.sparrow.event.WhirlpoolMixEvent;
import com.sparrowwallet.sparrow.event.WhirlpoolMixSuccessEvent;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger;
@ -57,10 +61,12 @@ public class Whirlpool {
private final JavaHttpClientService httpClientService;
private final JavaStompClientService stompClientService;
private final TorClientService torClientService;
private final WhirlpoolWalletService whirlpoolWalletService;
private final SparrowWhirlpoolWalletService whirlpoolWalletService;
private final WhirlpoolWalletConfig config;
private HD_Wallet hdWallet;
private BooleanProperty mixingProperty = new SimpleBooleanProperty(false);
public Whirlpool(Network network, HostAndPort torProxy, String sCode, int maxClients) {
this.torProxy = torProxy;
this.whirlpoolServer = WhirlpoolServer.valueOf(network.getName().toUpperCase());
@ -70,7 +76,7 @@ public class Whirlpool {
DataPersisterFactory dataPersisterFactory = (config, bip44w, walletIdentifier) -> new FileDataPersister(config, bip44w, walletIdentifier);
DataSourceFactory dataSourceFactory = (config, bip44w, walletIdentifier, dataPersister) -> new SparrowDataSource(config, bip44w, walletIdentifier, dataPersister);
this.whirlpoolWalletService = new WhirlpoolWalletService(dataPersisterFactory, dataSourceFactory);
this.whirlpoolWalletService = new SparrowWhirlpoolWalletService(dataPersisterFactory, dataSourceFactory);
this.config = computeWhirlpoolWalletConfig(sCode, maxClients);
WhirlpoolEventService.getInstance().register(this);
@ -134,7 +140,7 @@ public class Whirlpool {
return null;
}
public void setHDWallet(Wallet wallet) {
public void setHDWallet(String walletId, Wallet wallet) {
if(wallet.isEncrypted()) {
throw new IllegalStateException("Wallet cannot be encrypted");
}
@ -147,6 +153,7 @@ public class Whirlpool {
String passphrase = keystore.getSeed().getPassphrase().asString();
HD_WalletFactoryGeneric hdWalletFactory = HD_WalletFactoryGeneric.getInstance();
byte[] seed = hdWalletFactory.computeSeedFromWords(words);
whirlpoolWalletService.setWalletId(walletId);
hdWallet = new HD_Wallet(purpose, words, config.getNetworkParameters(), seed, passphrase, 1);
} catch(Exception e) {
throw new IllegalStateException("Could not create Whirlpool HD wallet ", e);
@ -169,6 +176,76 @@ public class Whirlpool {
}
}
public void stop() {
if(whirlpoolWalletService.whirlpoolWallet() != null) {
whirlpoolWalletService.whirlpoolWallet().stop();
}
}
public UtxoMixData getMixData(BlockTransactionHashIndex txo) {
if(whirlpoolWalletService.whirlpoolWallet() != null) {
UtxoConfigPersisted config = whirlpoolWalletService.whirlpoolWallet().getUtxoConfigSupplier().getUtxoConfigPersisted(txo.getHashAsString(), (int)txo.getIndex());
if(config != null) {
return new UtxoMixData(config.getPoolId(), config.getMixsDone(), config.getForwarding());
}
}
return null;
}
private void persistMixData() {
try {
whirlpoolWalletService.whirlpoolWallet().getUtxoConfigSupplier().persist(true);
} catch(Exception e) {
log.error("Error persisting mix data", e);
}
}
public void mix(BlockTransactionHashIndex utxo) throws WhirlpoolException {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
throw new WhirlpoolException("Whirlpool wallet not yet created");
}
try {
WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex());
whirlpoolWalletService.whirlpoolWallet().mixNow(whirlpoolUtxo);
} catch(Exception e) {
throw new WhirlpoolException(e.getMessage(), e);
}
}
public void mixStop(BlockTransactionHashIndex utxo) throws WhirlpoolException {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
throw new WhirlpoolException("Whirlpool wallet not yet created");
}
try {
WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex());
whirlpoolWalletService.whirlpoolWallet().mixStop(whirlpoolUtxo);
} catch(Exception e) {
throw new WhirlpoolException(e.getMessage(), e);
}
}
public MixProgress getMixProgress(BlockTransactionHashIndex utxo) {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
return null;
}
WhirlpoolUtxo whirlpoolUtxo = whirlpoolWalletService.whirlpoolWallet().getUtxoSupplier().findUtxo(utxo.getHashAsString(), (int)utxo.getIndex());
if(whirlpoolUtxo != null && whirlpoolUtxo.getUtxoState() != null) {
return whirlpoolUtxo.getUtxoState().getMixProgress();
}
return null;
}
public void refreshUtxos() {
if(whirlpoolWalletService.whirlpoolWallet() != null) {
whirlpoolWalletService.whirlpoolWallet().refreshUtxos();
}
}
public HostAndPort getTorProxy() {
return torProxy;
}
@ -190,6 +267,36 @@ public class Whirlpool {
httpClientService.shutdown();
}
private WalletUtxo getUtxo(WhirlpoolUtxo whirlpoolUtxo) {
Wallet wallet = AppServices.get().getWallet(whirlpoolWalletService.getWalletId());
if(wallet != null) {
StandardAccount standardAccount = getStandardAccount(whirlpoolUtxo.getAccount());
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) {
wallet = wallet.getChildWallet(standardAccount);
}
for(BlockTransactionHashIndex utxo : wallet.getWalletUtxos().keySet()) {
if(utxo.getHashAsString().equals(whirlpoolUtxo.getUtxo().tx_hash) && utxo.getIndex() == whirlpoolUtxo.getUtxo().tx_output_n) {
return new WalletUtxo(wallet, utxo);
}
}
}
return null;
}
public static StandardAccount getStandardAccount(WhirlpoolAccount whirlpoolAccount) {
if(whirlpoolAccount == WhirlpoolAccount.PREMIX) {
return StandardAccount.WHIRLPOOL_PREMIX;
} else if(whirlpoolAccount == WhirlpoolAccount.POSTMIX) {
return StandardAccount.WHIRLPOOL_POSTMIX;
} else if(whirlpoolAccount == WhirlpoolAccount.BADBANK) {
return StandardAccount.WHIRLPOOL_BADBANK;
}
return StandardAccount.ACCOUNT_0;
}
public static UnspentOutput getUnspentOutput(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) {
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get(index);
@ -235,24 +342,64 @@ public class Whirlpool {
config.setScode(scode);
}
public boolean isMixing() {
return mixingProperty.get();
}
public BooleanProperty mixingProperty() {
return mixingProperty;
}
@Subscribe
public void onMixSuccess(MixSuccessEvent e) {
WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo());
if(walletUtxo != null) {
log.debug("Mix success, new utxo " + e.getMixSuccess().getReceiveUtxo().getHash() + ":" + e.getMixSuccess().getReceiveUtxo().getIndex());
persistMixData();
Platform.runLater(() -> EventManager.get().post(new WhirlpoolMixSuccessEvent(walletUtxo.wallet, walletUtxo.utxo, e.getMixSuccess().getReceiveUtxo(), getReceiveNode(e, walletUtxo))));
}
}
private WalletNode getReceiveNode(MixSuccessEvent e, WalletUtxo walletUtxo) {
for(WalletNode walletNode : walletUtxo.wallet.getNode(KeyPurpose.RECEIVE).getChildren()) {
if(walletUtxo.wallet.getAddress(walletNode).toString().equals(e.getMixSuccess().getReceiveAddress())) {
return walletNode;
}
}
return null;
}
@Subscribe
public void onMixFail(MixFailEvent e) {
log.info("Mix failed for utxo " + e.getMixFail().getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getMixFail().getWhirlpoolUtxo().getUtxo().tx_output_n);
WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo());
if(walletUtxo != null) {
log.debug("Mix failed for utxo " + e.getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getWhirlpoolUtxo().getUtxo().tx_output_n + " " + e.getMixFailReason());
Platform.runLater(() -> EventManager.get().post(new WhirlpoolMixEvent(walletUtxo.wallet, walletUtxo.utxo, e.getMixFailReason())));
}
}
@Subscribe
public void onMixSuccess(MixSuccessEvent e) {
log.info("Mix success, new utxo " + e.getMixSuccess().getReceiveUtxo().getHash() + ":" + e.getMixSuccess().getReceiveUtxo().getIndex());
public void onMixProgress(MixProgressEvent e) {
WalletUtxo walletUtxo = getUtxo(e.getWhirlpoolUtxo());
if(walletUtxo != null && isMixing()) {
log.debug("Mix progress for utxo " + e.getWhirlpoolUtxo().getUtxo().tx_hash + ":" + e.getWhirlpoolUtxo().getUtxo().tx_output_n + " " + e.getWhirlpoolUtxo().getMixsDone() + " " + e.getMixProgress().getMixStep() + " " + e.getWhirlpoolUtxo().getUtxoState().getStatus());
Platform.runLater(() -> EventManager.get().post(new WhirlpoolMixEvent(walletUtxo.wallet, walletUtxo.utxo, e.getMixProgress())));
}
}
@Subscribe
public void onWalletStart(WalletStartEvent e) {
log.info("Wallet started");
if(e.getWhirlpoolWallet() == whirlpoolWalletService.whirlpoolWallet()) {
mixingProperty.set(true);
}
}
@Subscribe
public void onWalletStop(WalletStopEvent e) {
log.info("Wallet stopped");
if(e.getWhirlpoolWallet() == whirlpoolWalletService.whirlpoolWallet()) {
mixingProperty.set(false);
}
}
public static class PoolsService extends Service<Collection<Pool>> {
@ -369,4 +516,14 @@ public class Whirlpool {
};
}
}
public static class WalletUtxo {
public final Wallet wallet;
public final BlockTransactionHashIndex utxo;
public WalletUtxo(Wallet wallet, BlockTransactionHashIndex utxo) {
this.wallet = wallet;
this.utxo = utxo;
}
}
}

9
src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java

@ -94,6 +94,11 @@ public class WhirlpoolController {
Config.get().setScode(newValue);
});
if(Config.get().getScode() != null) {
step1.setVisible(false);
step3.setVisible(true);
}
pool.setConverter(new StringConverter<Pool>() {
@Override
public String toString(Pool pool) {
@ -219,6 +224,10 @@ public class WhirlpoolController {
}
private void fetchTx0Preview(Pool pool) {
if(Config.get().getScode() == null) {
Config.get().setScode("");
}
Whirlpool whirlpool = AppServices.get().getWhirlpool(walletId);
whirlpool.setScode(Config.get().getScode());

9
src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolDialog.java

@ -1,9 +1,9 @@
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.io.Config;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
@ -27,9 +27,10 @@ public class WhirlpoolDialog extends Dialog<Tx0Preview> {
whirlpoolController.initializeView(walletId, wallet, utxoEntries);
dialogPane.setPrefWidth(600);
dialogPane.setPrefHeight(520);
dialogPane.setPrefHeight(550);
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("whirlpool/whirlpool.css").toExternalForm());
final ButtonType nextButtonType = new javafx.scene.control.ButtonType("Next", ButtonBar.ButtonData.OK_DONE);
@ -53,7 +54,9 @@ public class WhirlpoolDialog extends Dialog<Tx0Preview> {
backButton.managedProperty().bind(backButton.visibleProperty());
previewButton.managedProperty().bind(previewButton.visibleProperty());
backButton.setDisable(true);
if(Config.get().getScode() == null) {
backButton.setDisable(true);
}
previewButton.visibleProperty().bind(nextButton.visibleProperty().not());
nextButton.addEventFilter(ActionEvent.ACTION, event -> {

13
src/main/resources/com/sparrowwallet/sparrow/app.css

@ -32,6 +32,19 @@
visibility: hidden;
}
.wallet-subtabs > .tab-header-area .tab {
-fx-pref-height: 50;
-fx-pref-width: 80;
-fx-alignment: CENTER;
}
.wallet-subtabs > .tab-header-area .tab-label {
-fx-pref-height: 50;
-fx-pref-width: 80;
-fx-alignment: CENTER;
-fx-translate-x: -6;
}
.status-bar .status-label {
-fx-alignment: center-left;
}

2
src/main/resources/com/sparrowwallet/sparrow/app.fxml

@ -10,7 +10,7 @@
<?import com.sparrowwallet.sparrow.Theme?>
<?import impl.org.controlsfx.skin.DecorationPane?>
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1020.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="200" minWidth="350" prefHeight="770.0" prefWidth="1070.0" fx:controller="com.sparrowwallet.sparrow.AppController" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1">
<children>
<MenuBar useSystemMenuBar="true">
<menus>

1
src/main/resources/com/sparrowwallet/sparrow/sql/V2__Whirlpool.sql

@ -0,0 +1 @@
create table utxoMixData (id identity not null, hash binary(32) not null, poolId varchar(32), mixesDone integer not null default 0, forwarding bigint, wallet bigint not null);

4
src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.css

@ -4,6 +4,10 @@
-fx-padding: 10 0 10 0;
}
.utxos-treetable .progress-bar > .bar {
-fx-padding: 0.6em;
}
.utxos-buttons-box {
-fx-padding: 15 0 0 0;
}

39
src/main/resources/com/sparrowwallet/sparrow/wallet/utxos.fxml

@ -38,18 +38,33 @@
<UtxosTreeTable fx:id="utxosTable" />
</center>
<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" />
</graphic>
</Button>
<HBox>
<HBox fx:id="mixButtonsBox" styleClass="utxos-buttons-box" spacing="20" alignment="BOTTOM_LEFT">
<Button fx:id="startMix" text="Start Mixing" onAction="#startMixing">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="RANDOM" fontSize="12" />
</graphic>
</Button>
<Button fx:id="stopMix" text="Stop Mixing" onAction="#stopMixing">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" icon="STOP_CIRCLE" fontSize="12" />
</graphic>
</Button>
</HBox>
<Region HBox.hgrow="ALWAYS" />
<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" />
</graphic>
</Button>
</HBox>
</HBox>
</bottom>
</BorderPane>

2
src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css

@ -119,7 +119,7 @@
-fx-fill: white;
}
.duplicate-warning {
.duplicate-warning, .fail-warning {
-fx-text-fill: rgb(202, 18, 67);
}

42
src/main/resources/com/sparrowwallet/sparrow/whirlpool/whirlpool.fxml

@ -39,9 +39,45 @@
<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" />
<HBox>
<VBox spacing="15">
<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, and should be treated carefully." 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>
<TabPane side="RIGHT" rotateGraphic="true" styleClass="wallet-subtabs" minWidth="100" minHeight="280">
<Tab text="" closable="false">
<graphic>
<Label text="Premix" contentDisplay="TOP">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="RANDOM" />
</graphic>
</Label>
</graphic>
<HBox/>
</Tab>
<Tab text="" closable="false">
<graphic>
<Label text="Postmix" contentDisplay="TOP">
<graphic>
<Glyph fontFamily="FontAwesome" fontSize="12" icon="SEND" />
</graphic>
</Label>
</graphic>
<HBox/>
</Tab>
<Tab text="" closable="false">
<graphic>
<Label text="Badbank" contentDisplay="TOP">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="BIOHAZARD" />
</graphic>
</Label>
</graphic>
<HBox/>
</Tab>
</TabPane>
</HBox>
</VBox>
<VBox fx:id="step3" spacing="15">
<Label text="Configure Whirlpool" styleClass="title-text">

5
src/main/resources/logback.xml

@ -31,9 +31,12 @@
<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.eclipse.jetty.util.log" level="OFF" />
<logger name="org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler" level="OFF" />
<logger name="org.bitcoinj.crypto.MnemonicCode" level="OFF" />
<logger name="org.springframework.core.KotlinDetector" level="OFF" />
<logger name="org.springframework.http.converter.json.Jackson2ObjectMapperBuilder" level="OFF" />
<logger name="org.springframework.web.HttpLogging" level="OFF" />
<contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"/>

Loading…
Cancel
Save