Browse Source

introduce nested wallet support to allow child wallets to contribute to the master wallet

terminal
Craig Raw 3 years ago
parent
commit
5959b00611
  1. 5
      build.gradle
  2. 2
      drongo
  3. 48
      src/main/java/com/sparrowwallet/sparrow/AppController.java
  4. 6
      src/main/java/com/sparrowwallet/sparrow/AppServices.java
  5. 4
      src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java
  6. 3
      src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java
  7. 6
      src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
  8. 35
      src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java
  9. 4
      src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
  10. 24
      src/main/java/com/sparrowwallet/sparrow/control/SearchWalletDialog.java
  11. 33
      src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java
  12. 2
      src/main/java/com/sparrowwallet/sparrow/control/TransactionsTreeTable.java
  13. 2
      src/main/java/com/sparrowwallet/sparrow/control/UtxosTreeTable.java
  14. 27
      src/main/java/com/sparrowwallet/sparrow/event/ChildWalletAddedEvent.java
  15. 35
      src/main/java/com/sparrowwallet/sparrow/event/ChildWalletsAddedEvent.java
  16. 16
      src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java
  17. 17
      src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java
  18. 26
      src/main/java/com/sparrowwallet/sparrow/event/WalletNodeHistoryChangedEvent.java
  19. 2
      src/main/java/com/sparrowwallet/sparrow/io/Electrum.java
  20. 3
      src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java
  21. 2
      src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
  22. 1
      src/main/java/com/sparrowwallet/sparrow/io/db/WalletDao.java
  23. 135
      src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
  24. 60
      src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java
  25. 2
      src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java
  26. 2
      src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java
  27. 30
      src/main/java/com/sparrowwallet/sparrow/soroban/SparrowCahootsWallet.java
  28. 10
      src/main/java/com/sparrowwallet/sparrow/transaction/HeadersController.java
  29. 2
      src/main/java/com/sparrowwallet/sparrow/wallet/AddressesController.java
  30. 10
      src/main/java/com/sparrowwallet/sparrow/wallet/Entry.java
  31. 6
      src/main/java/com/sparrowwallet/sparrow/wallet/HashIndexEntry.java
  32. 2
      src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java
  33. 6
      src/main/java/com/sparrowwallet/sparrow/wallet/NodeEntry.java
  34. 4
      src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
  35. 36
      src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
  36. 36
      src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java
  37. 8
      src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java
  38. 6
      src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java
  39. 4
      src/main/java/com/sparrowwallet/sparrow/wallet/UtxoEntry.java
  40. 12
      src/main/java/com/sparrowwallet/sparrow/wallet/UtxosController.java
  41. 85
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
  42. 17
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java
  43. 4
      src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java
  44. 13
      src/main/java/com/sparrowwallet/sparrow/whirlpool/Whirlpool.java
  45. 2
      src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolController.java
  46. 4
      src/main/java/com/sparrowwallet/sparrow/whirlpool/WhirlpoolServices.java
  47. 64
      src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowDataSource.java
  48. 3
      src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java
  49. 2
      src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java

5
build.gradle

@ -92,7 +92,7 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.30')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.32')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
@ -461,7 +461,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module('nightjar-0.2.30.jar', 'com.sparrowwallet.nightjar', '0.2.30') {
module('nightjar-0.2.32.jar', 'com.sparrowwallet.nightjar', '0.2.32') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@ -507,6 +507,7 @@ extraJavaModuleInfo {
exports('com.samourai.whirlpool.protocol.rest')
exports('com.samourai.whirlpool.client.tx0')
exports('com.samourai.wallet.segwit.bech32')
exports('com.samourai.whirlpool.client.wallet.data.chain')
exports('com.samourai.whirlpool.client.wallet.data.wallet')
exports('com.samourai.whirlpool.client.wallet.data.minerFee')
exports('com.samourai.whirlpool.client.wallet.data.walletState')

2
drongo

@ -1 +1 @@
Subproject commit 956f59880e508127b62d62022e3e2618f659f4d2
Subproject commit 0734757a177627600a63cb3347804ea126b0d417

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

@ -1046,12 +1046,14 @@ public class AppController implements Initializable {
}
}
if(wallet.isBip47()) {
try {
Keystore keystore = wallet.getKeystores().get(0);
keystore.setBip47ExtendedPrivateKey(wallet.getMasterWallet().getKeystores().get(0).getBip47ExtendedPrivateKey());
} catch(Exception e) {
log.error("Cannot prepare BIP47 keystore", e);
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isBip47()) {
try {
Keystore keystore = childWallet.getKeystores().get(0);
keystore.setBip47ExtendedPrivateKey(wallet.getKeystores().get(0).getBip47ExtendedPrivateKey());
} catch(Exception e) {
log.error("Cannot prepare BIP47 keystore", e);
}
}
}
}
@ -1183,7 +1185,9 @@ public class AppController implements Initializable {
addWalletTabOrWindow(storage, wallet, false);
for(Wallet childWallet : wallet.getChildWallets()) {
childWallet.encrypt(key);
if(!childWallet.isNested()) {
childWallet.encrypt(key);
}
storage.saveWallet(childWallet);
checkWalletNetwork(childWallet);
restorePublicKeysFromSeed(storage, childWallet, key);
@ -1488,14 +1492,20 @@ public class AppController implements Initializable {
if(tabData instanceof WalletTabData) {
WalletTabData walletTabData = (WalletTabData)tabData;
if(walletTabData.getWallet() == wallet.getMasterWallet()) {
TabPane subTabs = (TabPane)walletTab.getContent();
addWalletSubTab(subTabs, storage, wallet);
Tab masterTab = subTabs.getTabs().stream().filter(tab -> ((WalletTabData)tab.getUserData()).getWallet().isMasterWallet()).findFirst().orElse(subTabs.getTabs().get(0));
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(wallet.getMasterWallet().getLabel() != null ? wallet.getMasterWallet().getLabel() : wallet.getMasterWallet().getAutomaticName());
Platform.runLater(() -> {
setSubTabsVisible(subTabs, true);
});
if(wallet.isNested()) {
WalletForm walletForm = new WalletForm(storage, wallet);
EventManager.get().register(walletForm);
walletTabData.getWalletForm().getNestedWalletForms().add(walletForm);
} else {
TabPane subTabs = (TabPane)walletTab.getContent();
addWalletSubTab(subTabs, storage, wallet);
Tab masterTab = subTabs.getTabs().stream().filter(tab -> ((WalletTabData)tab.getUserData()).getWallet().isMasterWallet()).findFirst().orElse(subTabs.getTabs().get(0));
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(wallet.getMasterWallet().getLabel() != null ? wallet.getMasterWallet().getLabel() : wallet.getMasterWallet().getAutomaticName());
Platform.runLater(() -> {
setSubTabsVisible(subTabs, true);
});
}
}
}
}
@ -2268,7 +2278,7 @@ public class AppController implements Initializable {
@Subscribe
public void walletHistoryStarted(WalletHistoryStartedEvent event) {
if(AppServices.isConnected() && getOpenWallets().containsKey(event.getWallet())) {
if(event.getWalletNodes() == null && event.getWallet().getTransactions().isEmpty()) {
if(event.getWalletNodes() == null && !event.getWallet().hasTransactions()) {
statusUpdated(new StatusEvent(LOADING_TRANSACTIONS_MESSAGE, 120));
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(-1);
@ -2483,13 +2493,15 @@ public class AppController implements Initializable {
}
@Subscribe
public void childWalletAdded(ChildWalletAddedEvent event) {
public void childWalletsAdded(ChildWalletsAddedEvent event) {
Storage storage = AppServices.get().getOpenWallets().get(event.getWallet());
if(storage == null) {
throw new IllegalStateException("Cannot find storage for master wallet");
}
addWalletTab(storage, event.getChildWallet());
for(Wallet childWallet : event.getChildWallets()) {
addWalletTab(storage, childWallet);
}
}
@Subscribe

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

@ -689,6 +689,12 @@ public class AppServices {
public static void clearTransactionHistoryCache(Wallet wallet) {
ElectrumServer.clearRetrievedScriptHashes(wallet);
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isNested()) {
AppServices.clearTransactionHistoryCache(childWallet);
}
}
}
public static boolean isWalletFile(File file) {

4
src/main/java/com/sparrowwallet/sparrow/control/AddAccountDialog.java

@ -46,7 +46,9 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
existingIndexes.add(masterWallet.getAccountIndex());
for(Wallet childWallet : masterWallet.getChildWallets()) {
existingIndexes.add(childWallet.getAccountIndex());
if(!childWallet.isNested()) {
existingIndexes.add(childWallet.getAccountIndex());
}
}
List<StandardAccount> availableAccounts = new ArrayList<>();

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

@ -48,7 +48,8 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
}
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate) {
return utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : "");
return (utxoEntry.getNode().getWallet().isNested() ? utxoEntry.getNode().getWallet().getDisplayName() + " " : "" ) +
utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : "");
}
public static Glyph getDuplicateGlyph() {

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

@ -128,7 +128,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
actionBox.getChildren().add(receiveButton);
if(canSignMessage(nodeEntry.getWallet())) {
if(canSignMessage(nodeEntry.getNode().getWallet())) {
Button signMessageButton = new Button("");
signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> {
@ -277,7 +277,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
Payment payment = new Payment(transactionEntry.getWallet().getAddress(freshNode), label, utxo.getValue(), true);
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), blockTransaction.getFee(), false)));
@ -507,7 +507,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().add(receiveToAddress);
if(nodeEntry != null && canSignMessage(nodeEntry.getWallet())) {
if(nodeEntry != null && canSignMessage(nodeEntry.getNode().getWallet())) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setGraphic(getSignMessageGlyph());
signVerifyMessage.setOnAction(AE -> {

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

@ -89,13 +89,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
* @param buttons The dialog buttons to display. If one contains the text "sign" it will trigger the signing process
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
if(walletNode != null) {
checkWalletSigning(walletNode.getWallet());
}
if(wallet != null) {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore");
}
checkWalletSigning(wallet);
}
this.wallet = wallet;
@ -131,7 +130,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
addressField.getInputs().add(address);
if(walletNode != null) {
address.setText(wallet.getAddress(walletNode).toString());
address.setText(walletNode.getAddress().toString());
}
Field messageField = new Field();
@ -264,6 +263,15 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
}
private void checkWalletSigning(Wallet wallet) {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
}
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB) {
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a USB keystore");
}
}
private Address getAddress()throws InvalidAddressException {
return Address.fromString(address.getText());
}
@ -302,14 +310,15 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
//Note we can expect a single keystore due to the check in the constructor
if(wallet.getKeystores().get(0).hasPrivateKey()) {
if(wallet.isEncrypted()) {
Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
if(signingWallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
signUnencryptedKeystore(wallet);
signUnencryptedKeystore(signingWallet);
}
} else if(wallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(wallet);
} else if(signingWallet.containsSource(KeystoreSource.HW_USB)) {
signUsbKeystore(signingWallet);
}
}
@ -404,7 +413,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();

4
src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java

@ -168,13 +168,13 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
toWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> {
if(selectedWallet != null) {
toAddress.setText(selectedWallet.getAddress(selectedWallet.getFreshNode(KeyPurpose.RECEIVE)).toString());
toAddress.setText(selectedWallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString());
}
});
keyScriptType.setValue(ScriptType.P2PKH);
if(wallet != null) {
toAddress.setText(wallet.getAddress(wallet.getFreshNode(KeyPurpose.RECEIVE)).toString());
toAddress.setText(wallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString());
}
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(null));

24
src/main/java/com/sparrowwallet/sparrow/control/SearchWalletDialog.java

@ -68,14 +68,16 @@ public class SearchWalletDialog extends Dialog<Entry> {
fieldset.getChildren().addAll(searchField);
form.getChildren().add(fieldset);
boolean showWallet = walletForms.size() > 1 || walletForms.stream().anyMatch(walletForm -> !walletForm.getNestedWalletForms().isEmpty());
results = new CoinTreeTable();
results.setShowRoot(false);
results.setPrefWidth(walletForms.size() > 1 ? 950 : 850);
results.setPrefWidth(showWallet ? 950 : 850);
results.setBitcoinUnit(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results"));
if(walletForms.size() > 1) {
if(showWallet) {
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
@ -127,7 +129,8 @@ public class SearchWalletDialog extends Dialog<Entry> {
setResultConverter(buttonType -> buttonType == showButtonType ? results.getSelectionModel().getSelectedItem().getValue() : null);
results.getSelectionModel().getSelectedIndices().addListener((ListChangeListener<Integer>) c -> {
showButton.setDisable(results.getSelectionModel().getSelectedCells().isEmpty());
showButton.setDisable(results.getSelectionModel().getSelectedCells().isEmpty()
|| walletForms.stream().map(WalletForm::getWallet).noneMatch(wallet -> wallet == results.getSelectionModel().getSelectedItem().getValue().getWallet()));
});
search.textProperty().addListener((observable, oldValue, newValue) -> {
@ -176,6 +179,21 @@ public class SearchWalletDialog extends Dialog<Entry> {
}
}
for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) {
for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) {
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().contains(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase().contains(searchText)) ||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
}
}
}
}
WalletUtxosEntry walletUtxosEntry = walletForm.getWalletUtxosEntry();
for(Entry entry : walletUtxosEntry.getChildren()) {
if(entry instanceof HashIndexEntry hashIndexEntry) {

33
src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java

@ -209,7 +209,7 @@ public class TransactionDiagram extends GridPane {
private List<Map<BlockTransactionHashIndex, WalletNode>> getDisplayedUtxoSets() {
boolean addUserSet = getOptimizationStrategy() == OptimizationStrategy.PRIVACY && SorobanServices.canWalletMix(walletTx.getWallet())
&& walletTx.getPayments().size() == 1
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getAddress(walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets = new ArrayList<>();
for(Map<BlockTransactionHashIndex, WalletNode> selectedUtxoSet : walletTx.getSelectedUtxoSets()) {
@ -406,7 +406,9 @@ public class TransactionDiagram extends GridPane {
Long inputValue = null;
if(walletNode != null) {
inputValue = input.getValue();
tooltip.setText("Spending " + getSatsValue(inputValue) + " sats from " + (isFinal() ? walletTx.getWallet().getFullDisplayName() : "") + " " + walletNode + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
Wallet nodeWallet = walletNode.getWallet();
tooltip.setText("Spending " + getSatsValue(inputValue) + " sats from " + (isFinal() ? nodeWallet.getFullDisplayName() : (nodeWallet.isNested() ? nodeWallet.getDisplayName() : "")) + " " + walletNode + "\n" +
input.getHashAsString() + ":" + input.getIndex() + "\n" + walletNode.getAddress());
tooltip.getStyleClass().add("input-label");
if(input.getLabel() == null || input.getLabel().isEmpty()) {
@ -648,9 +650,10 @@ public class TransactionDiagram extends GridPane {
recipientLabel.getStyleClass().add(labelledPayment ? "payment-label" : "recipient-label");
Wallet toWallet = getToWallet(payment);
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getWallet().getWalletAddresses().get(payment.getAddress()) : null;
Wallet toBip47Wallet = getBip47SendWallet(payment);
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
+ getSatsValue(payment.getAmount()) + " sats to "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : "external address") : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientTooltip.setShowDuration(Duration.INDEFINITE);
@ -849,8 +852,28 @@ public class TransactionDiagram extends GridPane {
private Wallet getToWallet(Payment payment) {
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
if(openWallet != walletTx.getWallet() && openWallet.isValid() && !openWallet.isBip47() && openWallet.isWalletAddress(payment.getAddress())) {
return openWallet;
if(openWallet != walletTx.getWallet() && openWallet.isValid()) {
WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress());
if(addressNode != null) {
return addressNode.getWallet();
}
}
}
return null;
}
private Wallet getBip47SendWallet(Payment payment) {
if(walletTx.getWallet() != null) {
for(Wallet childWallet : walletTx.getWallet().getChildWallets()) {
if(childWallet.isNested()) {
WalletNode sendNode = childWallet.getNode(KeyPurpose.SEND);
for(WalletNode sendAddressNode : sendNode.getChildren()) {
if(sendAddressNode.getAddress().equals(payment.getAddress())) {
return childWallet;
}
}
}
}
}

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

@ -71,7 +71,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
}
}
public void updateHistory(List<WalletNode> updatedNodes) {
public void updateHistory() {
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
sort();
}

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

@ -99,7 +99,7 @@ public class UtxosTreeTable extends CoinTreeTable {
}
}
public void updateHistory(List<WalletNode> updatedNodes) {
public void updateHistory() {
//Utxo entries should have already been updated, so only a resort required
if(!getRoot().getChildren().isEmpty()) {
sort();

27
src/main/java/com/sparrowwallet/sparrow/event/ChildWalletAddedEvent.java

@ -1,27 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.io.Storage;
public class ChildWalletAddedEvent extends WalletChangedEvent {
private final Storage storage;
private final Wallet childWallet;
public ChildWalletAddedEvent(Storage storage, Wallet masterWallet, Wallet childWallet) {
super(masterWallet);
this.storage = storage;
this.childWallet = childWallet;
}
public Storage getStorage() {
return storage;
}
public Wallet getChildWallet() {
return childWallet;
}
public String getMasterWalletId() {
return storage.getWalletId(getWallet());
}
}

35
src/main/java/com/sparrowwallet/sparrow/event/ChildWalletsAddedEvent.java

@ -0,0 +1,35 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.io.Storage;
import java.util.List;
public class ChildWalletsAddedEvent extends WalletChangedEvent {
private final Storage storage;
private final List<Wallet> childWallets;
public ChildWalletsAddedEvent(Storage storage, Wallet masterWallet, Wallet childWallet) {
super(masterWallet);
this.storage = storage;
this.childWallets = List.of(childWallet);
}
public ChildWalletsAddedEvent(Storage storage, Wallet masterWallet, List<Wallet> childWallets) {
super(masterWallet);
this.storage = storage;
this.childWallets = childWallets;
}
public Storage getStorage() {
return storage;
}
public List<Wallet> getChildWallets() {
return childWallets;
}
public String getMasterWalletId() {
return storage.getWalletId(getWallet());
}
}

16
src/main/java/com/sparrowwallet/sparrow/event/WalletChangedEvent.java

@ -15,4 +15,20 @@ public class WalletChangedEvent {
public Wallet getWallet() {
return wallet;
}
public boolean fromThisOrNested(Wallet targetWallet) {
if(wallet.equals(targetWallet)) {
return true;
}
return wallet.isNested() && targetWallet.getChildWallets().contains(wallet);
}
public boolean toThisOrNested(Wallet targetWallet) {
if(wallet.equals(targetWallet)) {
return true;
}
return targetWallet.isNested() && wallet.getChildWallets().contains(targetWallet);
}
}

17
src/main/java/com/sparrowwallet/sparrow/event/WalletHistoryChangedEvent.java

@ -5,7 +5,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.io.Storage;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@ -16,11 +16,13 @@ import java.util.stream.Collectors;
public class WalletHistoryChangedEvent extends WalletChangedEvent {
private final Storage storage;
private final List<WalletNode> historyChangedNodes;
private final List<WalletNode> nestedHistoryChangedNodes;
public WalletHistoryChangedEvent(Wallet wallet, Storage storage, List<WalletNode> historyChangedNodes) {
public WalletHistoryChangedEvent(Wallet wallet, Storage storage, List<WalletNode> historyChangedNodes, List<WalletNode> nestedHistoryChangedNodes) {
super(wallet);
this.storage = storage;
this.historyChangedNodes = historyChangedNodes;
this.nestedHistoryChangedNodes = nestedHistoryChangedNodes;
}
public String getWalletId() {
@ -31,6 +33,17 @@ public class WalletHistoryChangedEvent extends WalletChangedEvent {
return historyChangedNodes;
}
public List<WalletNode> getNestedHistoryChangedNodes() {
return nestedHistoryChangedNodes;
}
public List<WalletNode> getAllHistoryChangedNodes() {
List<WalletNode> allHistoryChangedNodes = new ArrayList<>(historyChangedNodes.size() + nestedHistoryChangedNodes.size());
allHistoryChangedNodes.addAll(historyChangedNodes);
allHistoryChangedNodes.addAll(nestedHistoryChangedNodes);
return allHistoryChangedNodes;
}
public List<WalletNode> getReceiveNodes() {
return getWallet().getNode(KeyPurpose.RECEIVE).getChildren().stream().filter(historyChangedNodes::contains).collect(Collectors.toList());
}

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

@ -20,10 +20,17 @@ public class WalletNodeHistoryChangedEvent {
}
public WalletNode getWalletNode(Wallet wallet) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
WalletNode changedNode = getWalletNode(wallet, keyPurpose);
if(changedNode != null) {
return changedNode;
WalletNode changedNode = getNode(wallet);
if(changedNode != null) {
return changedNode;
}
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isNested()) {
changedNode = getNode(childWallet);
if(changedNode != null) {
return changedNode;
}
}
}
@ -38,6 +45,17 @@ public class WalletNodeHistoryChangedEvent {
return null;
}
private WalletNode getNode(Wallet wallet) {
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
WalletNode changedNode = getWalletNode(wallet, keyPurpose);
if(changedNode != null) {
return changedNode;
}
}
return null;
}
private WalletNode getWalletNode(Wallet wallet, KeyPurpose keyPurpose) {
WalletNode purposeNode = wallet.getNode(keyPurpose);
for(WalletNode addressNode : new ArrayList<>(purposeNode.getChildren())) {

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

@ -226,7 +226,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
WalletNode purposeNode = wallet.getNode(keyPurpose);
purposeNode.fillToIndex(keyPurposes.get(keyPurpose).size() - 1);
for(WalletNode addressNode : purposeNode.getChildren()) {
if(address.equals(wallet.getAddress(addressNode))) {
if(address.equals(addressNode.getAddress())) {
addressNode.setLabel(ew.labels.get(key));
}
}

3
src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java

@ -41,6 +41,7 @@ public class JsonPersistence implements Persistence {
try(Reader reader = new FileReader(storage.getWalletFile())) {
wallet = gson.fromJson(reader, Wallet.class);
wallet.getPurposeNodes().forEach(purposeNode -> purposeNode.setWallet(wallet));
}
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, wallet, null);
@ -63,6 +64,7 @@ public class JsonPersistence implements Persistence {
encryptionKey = getEncryptionKey(password, fileStream, alreadyDerivedKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
wallet = gson.fromJson(reader, Wallet.class);
wallet.getPurposeNodes().forEach(purposeNode -> purposeNode.setWallet(wallet));
}
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, wallet, encryptionKey);
@ -76,6 +78,7 @@ public class JsonPersistence implements Persistence {
Map<WalletAndKey, Storage> childWallets = new TreeMap<>();
for(File childFile : walletFiles) {
Wallet childWallet = loadWallet(childFile, encryptionKey);
childWallet.getPurposeNodes().forEach(purposeNode -> purposeNode.setWallet(childWallet));
Storage childStorage = new Storage(childFile);
childStorage.setEncryptionPubKey(encryptionKey == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(encryptionKey));
childStorage.setKeyDeriver(getKeyDeriver());

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

@ -692,7 +692,7 @@ public class DbPersistence implements Persistence {
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(persistsFor(event.getWallet())) {
if(persistsFor(event.getWallet()) && !event.getHistoryChangedNodes().isEmpty()) {
updateExecutor.execute(() -> dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).historyNodes.addAll(event.getHistoryChangedNodes()));
}
}

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

@ -102,6 +102,7 @@ public interface WalletDao {
List<WalletNode> walletNodes = createWalletNodeDao().getForWalletId(wallet.getId());
wallet.getPurposeNodes().addAll(walletNodes.stream().filter(walletNode -> walletNode.getDerivation().size() == 1).collect(Collectors.toList()));
wallet.getPurposeNodes().forEach(walletNode -> walletNode.setWallet(wallet));
Map<Sha256Hash, BlockTransaction> blockTransactions = createBlockTransactionDao().getForWalletId(wallet.getId());
wallet.updateTransactions(blockTransactions);

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

@ -669,7 +669,7 @@ public class ElectrumServer {
Set<BlockTransactionHashIndex> transactionOutputs = new TreeSet<>();
//First check all provided txes that pay to this node
Script nodeScript = wallet.getOutputScript(node);
Script nodeScript = node.getOutputScript();
Set<BlockTransactionHash> history = nodeTransactionMap.get(node);
for(BlockTransactionHash reference : history) {
BlockTransaction blockTransaction = wallet.getTransactions().get(reference.getHash());
@ -930,7 +930,7 @@ public class ElectrumServer {
}
public static String getScriptHash(Wallet wallet, WalletNode node) {
byte[] hash = Sha256Hash.hash(wallet.getOutputScript(node).getProgram());
byte[] hash = Sha256Hash.hash(node.getOutputScript().getProgram());
byte[] reversed = Utils.reverseBytes(hash);
return Utils.bytesToHex(reversed);
}
@ -1265,76 +1265,96 @@ public class ElectrumServer {
}
public static class TransactionHistoryService extends Service<Boolean> {
private final Wallet wallet;
private final Set<WalletNode> nodes;
private final Wallet mainWallet;
private final List<Wallet> filterToWallets;
private final Set<WalletNode> filterToNodes;
private final static Map<Wallet, Object> walletSynchronizeLocks = new HashMap<>();
public TransactionHistoryService(Wallet wallet) {
this.wallet = wallet;
this.nodes = null;
this.mainWallet = wallet;
this.filterToWallets = null;
this.filterToNodes = null;
}
public TransactionHistoryService(Wallet wallet, Set<WalletNode> nodes) {
this.wallet = wallet;
this.nodes = nodes;
public TransactionHistoryService(Wallet mainWallet, List<Wallet> filterToWallets, Set<WalletNode> filterToNodes) {
this.mainWallet = mainWallet;
this.filterToWallets = filterToWallets;
this.filterToNodes = filterToNodes;
}
@Override
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws ServerException {
boolean initial = (walletSynchronizeLocks.putIfAbsent(wallet, new Object()) == null);
synchronized(walletSynchronizeLocks.get(wallet)) {
if(initial) {
addCalculatedScriptHashes(wallet);
boolean historyFetched = getTransactionHistory(mainWallet);
for(Wallet childWallet : new ArrayList<>(mainWallet.getChildWallets())) {
if(childWallet.isNested()) {
historyFetched |= getTransactionHistory(childWallet);
}
}
if(isConnected()) {
ElectrumServer electrumServer = new ElectrumServer();
Map<String, String> previousScriptHashes = getCalculatedScriptHashes(wallet);
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
//Add all of the script hashes we have now fetched the history for so we don't need to fetch again until the script hash status changes
Set<WalletNode> updatedNodes = new HashSet<>();
Map<WalletNode, Set<BlockTransactionHashIndex>> walletNodes = wallet.getWalletNodes();
for(WalletNode node : (nodes == null ? walletNodes.keySet() : nodes)) {
String scriptHash = getScriptHash(wallet, node);
String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
if(!Objects.equals(subscribedStatus, retrievedScriptHashes.get(scriptHash))) {
updatedNodes.add(node);
}
retrievedScriptHashes.put(scriptHash, subscribedStatus);
}
return historyFetched;
}
};
}
//If wallet was not empty, check if all used updated nodes have changed history
if(nodes == null && previousScriptHashes.values().stream().anyMatch(Objects::nonNull)) {
if(!updatedNodes.isEmpty() && updatedNodes.equals(walletNodes.entrySet().stream().filter(entry -> !entry.getValue().isEmpty()).map(Map.Entry::getKey).collect(Collectors.toSet()))) {
//All used nodes on a non-empty wallet have changed history. Abort and trigger a full refresh.
log.info("All used nodes on a non-empty wallet have changed history. Triggering a full wallet refresh.");
throw new AllHistoryChangedException();
}
}
private boolean getTransactionHistory(Wallet wallet) throws ServerException {
if(filterToWallets != null && !filterToWallets.contains(wallet)) {
return false;
}
//Clear transaction outputs for nodes that have no history - this is useful when a transaction is replaced in the mempool
if(nodes != null) {
for(WalletNode node : nodes) {
String scriptHash = getScriptHash(wallet, node);
if(retrievedScriptHashes.get(scriptHash) == null && !node.getTransactionOutputs().isEmpty()) {
log.debug("Clearing transaction history for " + node);
node.getTransactionOutputs().clear();
}
}
}
boolean initial = (walletSynchronizeLocks.putIfAbsent(wallet, new Object()) == null);
synchronized(walletSynchronizeLocks.get(wallet)) {
if(initial) {
addCalculatedScriptHashes(wallet);
}
return true;
if(isConnected()) {
ElectrumServer electrumServer = new ElectrumServer();
Set<WalletNode> nodes = (filterToNodes == null ? null : filterToNodes.stream().filter(node -> node.getWallet().equals(wallet)).collect(Collectors.toSet()));
Map<String, String> previousScriptHashes = getCalculatedScriptHashes(wallet);
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
electrumServer.getReferencedTransactions(wallet, nodeTransactionMap);
electrumServer.calculateNodeHistory(wallet, nodeTransactionMap);
//Add all of the script hashes we have now fetched the history for so we don't need to fetch again until the script hash status changes
Set<WalletNode> updatedNodes = new HashSet<>();
Map<WalletNode, Set<BlockTransactionHashIndex>> walletNodes = wallet.getWalletNodes();
for(WalletNode node : (nodes == null ? walletNodes.keySet() : nodes)) {
String scriptHash = getScriptHash(wallet, node);
String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
if(!Objects.equals(subscribedStatus, retrievedScriptHashes.get(scriptHash))) {
updatedNodes.add(node);
}
retrievedScriptHashes.put(scriptHash, subscribedStatus);
}
return false;
//If wallet was not empty, check if all used updated nodes have changed history
if(nodes == null && previousScriptHashes.values().stream().anyMatch(Objects::nonNull)) {
if(!updatedNodes.isEmpty() && updatedNodes.equals(walletNodes.entrySet().stream().filter(entry -> !entry.getValue().isEmpty()).map(Map.Entry::getKey).collect(Collectors.toSet()))) {
//All used nodes on a non-empty wallet have changed history. Abort and trigger a full refresh.
log.info("All used nodes on a non-empty wallet have changed history. Triggering a full wallet refresh.");
throw new AllHistoryChangedException();
}
}
//Clear transaction outputs for nodes that have no history - this is useful when a transaction is replaced in the mempool
if(nodes != null) {
for(WalletNode node : nodes) {
String scriptHash = getScriptHash(wallet, node);
if(retrievedScriptHashes.get(scriptHash) == null && !node.getTransactionOutputs().isEmpty()) {
log.debug("Clearing transaction history for " + node);
node.getTransactionOutputs().clear();
}
}
}
return true;
}
};
return false;
}
}
}
@ -1696,9 +1716,18 @@ public class ElectrumServer {
Wallet addedWallet = wallet.addChildWallet(paymentCode, childScriptType, output, blkTx);
if(payNym != null) {
addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName());
} else {
addedWallet.setLabel(paymentCode.toAbbreviatedString() + " " + childScriptType.getName());
}
//Check this is a valid payment code, will throw IllegalArgumentException if not
addedWallet.getPubKey(new WalletNode(KeyPurpose.RECEIVE, 0));
try {
WalletNode receiveNode = new WalletNode(addedWallet, KeyPurpose.RECEIVE, 0);
receiveNode.getPubKey();
} catch(IllegalArgumentException e) {
wallet.getChildWallets().remove(addedWallet);
throw e;
}
addedWallets.add(addedWallet);
}
}

60
src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java

@ -296,7 +296,7 @@ public class PayNymController {
}
public boolean isLinked(PayNym payNym) {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
PaymentCode externalPaymentCode = payNym.paymentCode();
return getMasterWallet().getChildWallet(externalPaymentCode, payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH) != null;
}
@ -305,7 +305,7 @@ public class PayNymController {
Map<BlockTransaction, WalletNode> unlinkedNotifications = new HashMap<>();
for(PayNym payNym : following) {
if(!isLinked(payNym)) {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
PaymentCode externalPaymentCode = payNym.paymentCode();
Map<BlockTransaction, WalletNode> unlinkedNotification = getMasterWallet().getNotificationTransaction(externalPaymentCode);
if(!unlinkedNotification.isEmpty()) {
unlinkedNotifications.putAll(unlinkedNotification);
@ -345,27 +345,37 @@ public class PayNymController {
}
private void addWalletIfNotificationTransactionPresent(Wallet decryptedWallet, Map<BlockTransaction, PayNym> unlinkedPayNyms, Map<BlockTransaction, WalletNode> unlinkedNotifications) {
List<Wallet> addedWallets = new ArrayList<>();
for(BlockTransaction blockTransaction : unlinkedNotifications.keySet()) {
try {
PayNym payNym = unlinkedPayNyms.get(blockTransaction);
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
ECKey input0Key = decryptedWallet.getKeystores().get(0).getKey(unlinkedNotifications.get(blockTransaction));
TransactionOutPoint input0Outpoint = com.sparrowwallet.drongo.bip47.PaymentCode.getDesignatedInput(blockTransaction.getTransaction()).getOutpoint();
PaymentCode externalPaymentCode = payNym.paymentCode();
WalletNode input0Node = unlinkedNotifications.get(blockTransaction);
Keystore keystore = input0Node.getWallet().isNested() ? decryptedWallet.getChildWallet(input0Node.getWallet().getName()).getKeystores().get(0) : decryptedWallet.getKeystores().get(0);
ECKey input0Key = keystore.getKey(input0Node);
TransactionOutPoint input0Outpoint = PaymentCode.getDesignatedInput(blockTransaction.getTransaction()).getOutpoint();
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
byte[] blindingMask = com.sparrowwallet.drongo.bip47.PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.blind(getMasterWallet().getPaymentCode().getPayload(), blindingMask);
byte[] opReturnData = com.sparrowwallet.drongo.bip47.PaymentCode.getOpReturnData(blockTransaction.getTransaction());
byte[] blindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = PaymentCode.blind(getMasterWallet().getPaymentCode().getPayload(), blindingMask);
byte[] opReturnData = PaymentCode.getOpReturnData(blockTransaction.getTransaction());
if(Arrays.equals(opReturnData, blindedPaymentCode)) {
addChildWallet(payNym, externalPaymentCode);
followingList.refresh();
addedWallets.addAll(addChildWallets(payNym, externalPaymentCode));
}
} catch(Exception e) {
log.error("Error adding linked contact from notification transaction", e);
}
}
if(!addedWallets.isEmpty()) {
Wallet masterWallet = getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
EventManager.get().post(new ChildWalletsAddedEvent(storage, masterWallet, addedWallets));
followingList.refresh();
}
}
public void addChildWallet(PayNym payNym, com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode) {
public List<Wallet> addChildWallets(PayNym payNym, PaymentCode externalPaymentCode) {
List<Wallet> addedWallets = new ArrayList<>();
Wallet masterWallet = getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
List<ScriptType> scriptTypes = masterWallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes();
@ -380,8 +390,10 @@ public class PayNymController {
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
}
}
EventManager.get().post(new ChildWalletAddedEvent(storage, masterWallet, addedWallet));
addedWallets.add(addedWallet);
}
return addedWallets;
}
public void linkPayNym(PayNym payNym) {
@ -412,7 +424,7 @@ public class PayNymController {
}
final WalletTransaction walletTx = walletTransaction;
final com.sparrowwallet.drongo.bip47.PaymentCode paymentCode = masterWallet.getPaymentCode();
final PaymentCode paymentCode = masterWallet.getPaymentCode();
Wallet wallet = walletTransaction.getWallet();
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(wallet.isEncrypted()) {
@ -439,15 +451,16 @@ public class PayNymController {
}
}
private void broadcastNotificationTransaction(Wallet decryptedWallet, WalletTransaction walletTransaction, com.sparrowwallet.drongo.bip47.PaymentCode paymentCode, PayNym payNym) {
private void broadcastNotificationTransaction(Wallet decryptedWallet, WalletTransaction walletTransaction, PaymentCode paymentCode, PayNym payNym) {
try {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
PaymentCode externalPaymentCode = payNym.paymentCode();
WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue();
ECKey input0Key = decryptedWallet.getKeystores().get(0).getKey(input0Node);
Keystore keystore = input0Node.getWallet().isNested() ? decryptedWallet.getChildWallet(input0Node.getWallet().getName()).getKeystores().get(0) : decryptedWallet.getKeystores().get(0);
ECKey input0Key = keystore.getKey(input0Node);
TransactionOutPoint input0Outpoint = walletTransaction.getTransaction().getInputs().iterator().next().getOutpoint();
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
byte[] blindingMask = com.sparrowwallet.drongo.bip47.PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.blind(paymentCode.getPayload(), blindingMask);
byte[] blindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = PaymentCode.blind(paymentCode.getPayload(), blindingMask);
WalletTransaction finalWalletTx = getWalletTransaction(decryptedWallet, payNym, blindedPaymentCode, walletTransaction.getSelectedUtxos().keySet());
PSBT psbt = finalWalletTx.createPSBT();
@ -465,11 +478,14 @@ public class PayNymController {
Set<String> scriptHashes = transactionMempoolService.getValue();
if(!scriptHashes.isEmpty()) {
transactionMempoolService.cancel();
addChildWallet(payNym, externalPaymentCode);
List<Wallet> addedWallets = addChildWallets(payNym, externalPaymentCode);
Wallet masterWallet = getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
EventManager.get().post(new ChildWalletsAddedEvent(storage, masterWallet, addedWallets));
retrievePayNymProgress.setVisible(false);
followingList.refresh();
BlockTransaction blockTransaction = walletTransaction.getWallet().getTransactions().get(transaction.getTxId());
BlockTransaction blockTransaction = walletTransaction.getWallet().getWalletTransaction(transaction.getTxId());
if(blockTransaction != null && blockTransaction.getLabel() == null) {
blockTransaction.setLabel("Link " + payNym.nymName());
TransactionEntry transactionEntry = new TransactionEntry(walletTransaction.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap());
@ -512,7 +528,7 @@ public class PayNymController {
}
private WalletTransaction getWalletTransaction(Wallet wallet, PayNym payNym, byte[] blindedPaymentCode, Collection<BlockTransactionHashIndex> utxos) throws InsufficientFundsException {
com.sparrowwallet.drongo.bip47.PaymentCode externalPaymentCode = com.sparrowwallet.drongo.bip47.PaymentCode.fromString(payNym.paymentCode().toString());
PaymentCode externalPaymentCode = payNym.paymentCode();
Payment payment = new Payment(externalPaymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false);
List<Payment> payments = List.of(payment);
List<byte[]> opReturns = List.of(blindedPaymentCode);
@ -549,7 +565,7 @@ public class PayNymController {
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
List<Entry> changedLabelEntries = new ArrayList<>();
for(Map.Entry<Sha256Hash, PayNym> notificationTx : notificationTransactions.entrySet()) {
BlockTransaction blockTransaction = event.getWallet().getTransactions().get(notificationTx.getKey());
BlockTransaction blockTransaction = event.getWallet().getWalletTransaction(notificationTx.getKey());
if(blockTransaction != null && blockTransaction.getLabel() == null) {
blockTransaction.setLabel("Link " + notificationTx.getValue().nymName());
changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()));

2
src/main/java/com/sparrowwallet/sparrow/soroban/CounterpartyController.java

@ -287,7 +287,7 @@ public class CounterpartyController extends SorobanController {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : walletUtxos.entrySet()) {
counterpartyCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex());
counterpartyCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
try {

2
src/main/java/com/sparrowwallet/sparrow/soroban/InitiatorController.java

@ -433,7 +433,7 @@ public class InitiatorController extends SorobanController {
Payment payment = walletTransaction.getPayments().get(0);
Map<BlockTransactionHashIndex, WalletNode> firstSetUtxos = walletTransaction.isCoinControlUsed() ? walletTransaction.getSelectedUtxoSets().get(0) : wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : firstSetUtxos.entrySet()) {
initiatorCahootsWallet.addUtxo(wallet, entry.getValue(), wallet.getTransactions().get(entry.getKey().getHash()), (int)entry.getKey().getIndex());
initiatorCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet);

30
src/main/java/com/sparrowwallet/sparrow/soroban/SparrowCahootsWallet.java

@ -2,12 +2,16 @@ package com.sparrowwallet.sparrow.soroban;
import com.samourai.soroban.client.SorobanServer;
import com.samourai.wallet.api.backend.beans.UnspentOutput;
import com.samourai.wallet.bip47.rpc.PaymentAddress;
import com.samourai.wallet.bip47.rpc.PaymentCode;
import com.samourai.wallet.bip47.rpc.java.Bip47UtilJava;
import com.samourai.wallet.cahoots.CahootsUtxo;
import com.samourai.wallet.cahoots.SimpleCahootsWallet;
import com.samourai.wallet.hd.HD_Address;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.wallet.send.MyTransactionOutPoint;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
@ -32,11 +36,29 @@ public class SparrowCahootsWallet extends SimpleCahootsWallet {
bip84w.getAccount(account).getChange().setAddrIdx(wallet.getFreshNode(KeyPurpose.CHANGE).getIndex());
}
public void addUtxo(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) {
UnspentOutput unspentOutput = Whirlpool.getUnspentOutput(wallet, node, blockTransaction, index);
public void addUtxo(WalletNode node, BlockTransaction blockTransaction, int index) {
if(node.getWallet().getScriptType() != ScriptType.P2WPKH) {
return;
}
UnspentOutput unspentOutput = Whirlpool.getUnspentOutput(node, blockTransaction, index);
MyTransactionOutPoint myTransactionOutPoint = unspentOutput.computeOutpoint(getParams());
HD_Address hdAddress = getBip84Wallet().getAddressAt(account, unspentOutput);
CahootsUtxo cahootsUtxo = new CahootsUtxo(myTransactionOutPoint, node.getDerivationPath(), hdAddress.getECKey());
CahootsUtxo cahootsUtxo;
if(node.getWallet().isBip47()) {
try {
String strPaymentCode = node.getWallet().getKeystores().get(0).getExternalPaymentCode().toString();
HD_Address hdAddress = getBip47Wallet().getAccount(getBip47Account()).addressAt(node.getIndex());
PaymentAddress paymentAddress = Bip47UtilJava.getInstance().getPaymentAddress(new PaymentCode(strPaymentCode), 0, hdAddress, getParams());
cahootsUtxo = new CahootsUtxo(myTransactionOutPoint, node.getDerivationPath(), paymentAddress.getReceiveECKey());
} catch(Exception e) {
throw new IllegalStateException("Cannot add BIP47 UTXO", e);
}
} else {
HD_Address hdAddress = getBip84Wallet().getAddressAt(account, unspentOutput);
cahootsUtxo = new CahootsUtxo(myTransactionOutPoint, node.getDerivationPath(), hdAddress.getECKey());
}
addUtxo(account, cahootsUtxo);
}

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

@ -406,7 +406,7 @@ public class HeadersController extends TransactionFormController implements Init
} else {
Wallet wallet = getWalletFromTransactionInputs();
if(wallet != null) {
feeAmt = calculateFee(wallet.getTransactions());
feeAmt = calculateFee(wallet.getWalletTransactions());
}
}
@ -565,7 +565,7 @@ public class HeadersController extends TransactionFormController implements Init
Map<Sha256Hash, BlockTransaction> walletInputTransactions = inputTransactions;
if(walletInputTransactions == null) {
Set<Sha256Hash> refs = headersForm.getTransaction().getInputs().stream().map(txInput -> txInput.getOutpoint().getHash()).collect(Collectors.toSet());
walletInputTransactions = new HashMap<>(wallet.getTransactions());
walletInputTransactions = wallet.getWalletTransactions();
walletInputTransactions.keySet().retainAll(refs);
}
@ -1092,8 +1092,8 @@ public class HeadersController extends TransactionFormController implements Init
public void update() {
BlockTransaction blockTransaction = headersForm.getBlockTransaction();
Sha256Hash txId = headersForm.getTransaction().getTxId();
if(headersForm.getSigningWallet() != null && headersForm.getSigningWallet().getTransactions().containsKey(txId)) {
blockTransaction = headersForm.getSigningWallet().getTransactions().get(txId);
if(headersForm.getSigningWallet() != null && headersForm.getSigningWallet().getWalletTransaction(txId) != null) {
blockTransaction = headersForm.getSigningWallet().getWalletTransaction(txId);
}
if(blockTransaction != null && AppServices.getCurrentBlockHeight() != null) {
@ -1341,7 +1341,7 @@ public class HeadersController extends TransactionFormController implements Init
Sha256Hash txid = headersForm.getTransaction().getTxId();
List<Entry> changedLabelEntries = new ArrayList<>();
BlockTransaction blockTransaction = event.getWallet().getTransactions().get(txid);
BlockTransaction blockTransaction = event.getWallet().getWalletTransaction(txid);
if(blockTransaction != null && blockTransaction.getLabel() == null) {
blockTransaction.setLabel(headersForm.getName());
changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()));

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

@ -137,7 +137,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(indexNode.getAddress().toString());
writer.write(getDerivationPath(indexNode));
Optional<Entry> optLabelEntry = getWalletForm().getNodeEntry(keyPurpose).getChildren().stream()
.filter(entry -> ((NodeEntry)entry).getNode().getIndex() == indexNode.getIndex()).findFirst();

10
src/main/java/com/sparrowwallet/sparrow/wallet/Entry.java

@ -46,6 +46,16 @@ public abstract class Entry {
public abstract Function getWalletFunction();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Entry)) return false;
Entry entry = (Entry) o;
return wallet.equals(entry.wallet)
|| (wallet.isNested() && entry.wallet.getChildWallets().contains(wallet))
|| (entry.wallet.isNested() && wallet.getChildWallets().contains(entry.wallet));
}
public void updateLabel(Entry entry) {
if(this.equals(entry)) {
labelProperty.set(entry.getLabel());

6
src/main/java/com/sparrowwallet/sparrow/wallet/HashIndexEntry.java

@ -20,7 +20,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
private final KeyPurpose keyPurpose;
public HashIndexEntry(Wallet wallet, BlockTransactionHashIndex hashIndex, Type type, KeyPurpose keyPurpose) {
super(wallet, hashIndex.getLabel(), hashIndex.getSpentBy() != null ? List.of(new HashIndexEntry(wallet, hashIndex.getSpentBy(), Type.INPUT, keyPurpose)) : Collections.emptyList());
super(wallet.isNested() ? wallet.getMasterWallet() : wallet, hashIndex.getLabel(), hashIndex.getSpentBy() != null ? List.of(new HashIndexEntry(wallet, hashIndex.getSpentBy(), Type.INPUT, keyPurpose)) : Collections.emptyList());
this.hashIndex = hashIndex;
this.type = type;
this.keyPurpose = keyPurpose;
@ -46,7 +46,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
}
public BlockTransaction getBlockTransaction() {
return getWallet().getTransactions().get(hashIndex.getHash());
return getWallet().getWalletTransaction(hashIndex.getHash());
}
public String getDescription() {
@ -88,7 +88,7 @@ public class HashIndexEntry extends Entry implements Comparable<HashIndexEntry>
if (this == o) return true;
if (!(o instanceof HashIndexEntry)) return false;
HashIndexEntry that = (HashIndexEntry) o;
return getWallet().equals(that.getWallet()) &&
return super.equals(that) &&
hashIndex.equals(that.hashIndex) &&
type == that.type &&
keyPurpose == that.keyPurpose;

2
src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java

@ -450,7 +450,7 @@ public class KeystoreController extends WalletFormController implements Initiali
}
@Subscribe
public void childWalletAdded(ChildWalletAddedEvent event) {
public void childWalletsAdded(ChildWalletsAddedEvent event) {
if(event.getMasterWalletId().equals(walletForm.getWalletId())) {
setInputFieldsDisabled(keystore.getSource() != KeystoreSource.SW_WATCH);
}

6
src/main/java/com/sparrowwallet/sparrow/wallet/NodeEntry.java

@ -32,15 +32,15 @@ public class NodeEntry extends Entry implements Comparable<NodeEntry> {
}
public Address getAddress() {
return getWallet().getAddress(node);
return node.getAddress();
}
public Script getOutputScript() {
return getWallet().getOutputScript(node);
return node.getOutputScript();
}
public String getOutputDescriptor() {
return getWallet().getOutputDescriptor(node);
return node.getOutputDescriptor();
}
public void refreshChildren() {

4
src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java

@ -176,7 +176,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
} else if(newValue != null) {
WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE);
Address freshAddress = newValue.getAddress(freshNode);
Address freshAddress = freshNode.getAddress();
address.setText(freshAddress.toString());
label.requestFocus();
}
@ -326,7 +326,7 @@ public class PaymentController extends WalletFormController implements Initializ
Wallet recipientBip47Wallet = getWalletForPayNym(payNymProperty.get());
if(recipientBip47Wallet != null) {
WalletNode sendNode = recipientBip47Wallet.getFreshNode(KeyPurpose.SEND);
ECKey pubKey = recipientBip47Wallet.getPubKey(sendNode);
ECKey pubKey = sendNode.getPubKey();
Address address = recipientBip47Wallet.getScriptType().getAddress(pubKey);
if(sendController.getPaymentTabs().getTabs().size() > 1 || (getRecipientValueSats() != null && getRecipientValueSats() > getRecipientDustThreshold(address))) {
return address;

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

@ -4,6 +4,7 @@ import com.google.common.eventbus.Subscribe;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
@ -606,9 +607,9 @@ public class SendController extends WalletFormController implements Initializabl
OptimizationStrategy optimizationStrategy = (OptimizationStrategy)optimizationToggleGroup.getSelectedToggle().getUserData();
if(optimizationStrategy == OptimizationStrategy.PRIVACY
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType())
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType())
&& !(payments.get(0).getAddress() instanceof PayNymAddress)) {
selectors.add(new StonewallUtxoSelector(noInputsFee));
selectors.add(new StonewallUtxoSelector(payments.get(0).getAddress().getScriptType(), noInputsFee));
}
selectors.addAll(List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee)));
@ -810,7 +811,7 @@ public class SendController extends WalletFormController implements Initializabl
private void setEffectiveFeeRate(WalletTransaction walletTransaction) {
List<BlockTransaction> unconfirmedUtxoTxs = walletTransaction.getSelectedUtxos().keySet().stream().filter(ref -> ref.getHeight() <= 0)
.map(ref -> getWalletForm().getWallet().getTransactions().get(ref.getHash())).filter(Objects::nonNull).distinct().collect(Collectors.toList());
.map(ref -> getWalletForm().getWallet().getWalletTransaction(ref.getHash())).filter(Objects::nonNull).distinct().collect(Collectors.toList());
if(!unconfirmedUtxoTxs.isEmpty()) {
long utxoTxFee = unconfirmedUtxoTxs.stream().mapToLong(BlockTransaction::getFee).sum();
double utxoTxSize = unconfirmedUtxoTxs.stream().mapToDouble(blkTx -> blkTx.getTransaction().getVirtualSize()).sum();
@ -966,7 +967,7 @@ public class SendController extends WalletFormController implements Initializabl
private boolean isMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null || SorobanServices.canWalletMix(walletForm.getWallet()))
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
}
private void updateOptimizationButtons(List<Payment> payments) {
@ -1141,12 +1142,14 @@ public class SendController extends WalletFormController implements Initializabl
//Ensure all child wallets have been saved
Wallet masterWallet = getWalletForm().getWallet().isMasterWallet() ? getWalletForm().getWallet() : getWalletForm().getWallet().getMasterWallet();
for(Wallet childWallet : masterWallet.getChildWallets()) {
Storage storage = AppServices.get().getOpenWallets().get(childWallet);
if(!storage.isPersisted(childWallet)) {
try {
storage.saveWallet(childWallet);
} catch(Exception e) {
AppServices.showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage());
if(!childWallet.isNested()) {
Storage storage = AppServices.get().getOpenWallets().get(childWallet);
if(!storage.isPersisted(childWallet)) {
try {
storage.saveWallet(childWallet);
} catch(Exception e) {
AppServices.showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage());
}
}
}
}
@ -1201,8 +1204,8 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet()) && walletForm.getCreatedWalletTransaction() != null) {
if(walletForm.getCreatedWalletTransaction().getSelectedUtxos() != null && allSelectedUtxosSpent(event.getHistoryChangedNodes())) {
if(event.fromThisOrNested(walletForm.getWallet()) && walletForm.getCreatedWalletTransaction() != null) {
if(walletForm.getCreatedWalletTransaction().getSelectedUtxos() != null && allSelectedUtxosSpent(event.getAllHistoryChangedNodes())) {
clear(null);
} else {
updateTransaction();
@ -1232,7 +1235,7 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void walletEntryLabelChanged(WalletEntryLabelsChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
if(event.fromThisOrNested(walletForm.getWallet())) {
updateTransaction();
}
}
@ -1367,7 +1370,7 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
if(event.fromThisOrNested(getWalletForm().getWallet())) {
UtxoSelector utxoSelector = utxoSelectorProperty.get();
if(utxoSelector instanceof MaxUtxoSelector) {
updateTransaction(true);
@ -1424,12 +1427,13 @@ public class SendController extends WalletFormController implements Initializabl
public PrivacyAnalysisTooltip(WalletTransaction walletTransaction) {
List<Payment> payments = walletTransaction.getPayments();
List<Payment> userPayments = payments.stream().filter(payment -> payment.getType() != Payment.Type.FAKE_MIX).collect(Collectors.toList());
Map<Address, WalletNode> walletAddresses = getWalletForm().getWallet().getWalletAddresses();
OptimizationStrategy optimizationStrategy = getPreferredOptimizationStrategy();
boolean payNymPresent = isPayNymMixOnlyPayment(payments);
boolean fakeMixPresent = payments.stream().anyMatch(payment -> payment.getType() == Payment.Type.FAKE_MIX);
boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0);
boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getAddress(getWalletForm().wallet.getFreshNode(KeyPurpose.RECEIVE)).getScriptType());
boolean addressReuse = userPayments.stream().anyMatch(payment -> getWalletForm().getWallet().getWalletAddresses().get(payment.getAddress()) != null && !getWalletForm().getWallet().getWalletAddresses().get(payment.getAddress()).getTransactionOutputs().isEmpty());
boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
boolean addressReuse = userPayments.stream().anyMatch(payment -> walletAddresses.get(payment.getAddress()) != null && !walletAddresses.get(payment.getAddress()).getTransactionOutputs().isEmpty());
if(optimizationStrategy == OptimizationStrategy.PRIVACY) {
if(payNymPresent) {

36
src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java

@ -548,7 +548,7 @@ public class SettingsController extends WalletFormController implements Initiali
Wallet childWallet = masterWallet.addChildWallet(entry.getKey());
childWallet.getKeystores().clear();
childWallet.getKeystores().add(entry.getValue());
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
EventManager.get().post(new ChildWalletsAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
saveChildWallets(masterWallet);
}
@ -556,7 +556,7 @@ public class SettingsController extends WalletFormController implements Initiali
} else {
for(StandardAccount standardAccount : standardAccounts) {
Wallet childWallet = masterWallet.addChildWallet(standardAccount);
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
EventManager.get().post(new ChildWalletsAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
}
}
@ -568,7 +568,7 @@ public class SettingsController extends WalletFormController implements Initiali
} finally {
masterWallet.encrypt(key);
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isEncrypted()) {
if(!childWallet.isNested() && !childWallet.isEncrypted()) {
childWallet.encrypt(key);
}
}
@ -587,7 +587,7 @@ public class SettingsController extends WalletFormController implements Initiali
WhirlpoolServices.prepareWhirlpoolWallet(masterWallet, getWalletForm().getWalletId(), getWalletForm().getStorage());
} else {
Wallet childWallet = masterWallet.addChildWallet(standardAccount);
EventManager.get().post(new ChildWalletAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
EventManager.get().post(new ChildWalletsAddedEvent(getWalletForm().getStorage(), masterWallet, childWallet));
}
saveChildWallets(masterWallet);
@ -595,13 +595,15 @@ public class SettingsController extends WalletFormController implements Initiali
private void saveChildWallets(Wallet masterWallet) {
for(Wallet childWallet : masterWallet.getChildWallets()) {
Storage storage = AppServices.get().getOpenWallets().get(childWallet);
if(!storage.isPersisted(childWallet)) {
try {
storage.saveWallet(childWallet);
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage());
if(!childWallet.isNested()) {
Storage storage = AppServices.get().getOpenWallets().get(childWallet);
if(!storage.isPersisted(childWallet)) {
try {
storage.saveWallet(childWallet);
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + childWallet.getName(), e.getMessage());
}
}
}
}
@ -679,7 +681,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
@Subscribe
public void childWalletAdded(ChildWalletAddedEvent event) {
public void childWalletsAdded(ChildWalletsAddedEvent event) {
if(event.getMasterWalletId().equals(walletForm.getWalletId())) {
setInputFieldsDisabled(true);
}
@ -701,7 +703,7 @@ public class SettingsController extends WalletFormController implements Initiali
requirement = WalletPasswordDialog.PasswordRequirement.UPDATE_SET;
}
if(!changePassword && ((SettingsWalletForm)walletForm).isAddressChange() && !walletForm.getWallet().getTransactions().isEmpty()) {
if(!changePassword && ((SettingsWalletForm)walletForm).isAddressChange() && walletForm.getWallet().hasTransactions()) {
Optional<ButtonType> optResponse = AppServices.showWarningDialog("Change Wallet Addresses?", "This wallet has existing transactions which will be replaced as the wallet addresses will change. Ok to proceed?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.CANCEL)) {
revert.setDisable(false);
@ -764,7 +766,9 @@ public class SettingsController extends WalletFormController implements Initiali
walletForm.getStorage().setEncryptionPubKey(null);
masterWallet.decrypt(key);
for(Wallet childWallet : masterWallet.getChildWallets()) {
childWallet.decrypt(key);
if(!childWallet.isNested()) {
childWallet.decrypt(key);
}
}
saveWallet(true, false);
return;
@ -776,7 +780,9 @@ public class SettingsController extends WalletFormController implements Initiali
masterWallet.encrypt(key);
for(Wallet childWallet : masterWallet.getChildWallets()) {
childWallet.encrypt(key);
if(!childWallet.isNested()) {
childWallet.encrypt(key);
}
}
walletForm.getStorage().setEncryptionPubKey(encryptionPubKey);
walletForm.saveAndRefresh();

8
src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java

@ -28,7 +28,7 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
private final BlockTransaction blockTransaction;
public TransactionEntry(Wallet wallet, BlockTransaction blockTransaction, Map<BlockTransactionHashIndex, KeyPurpose> inputs, Map<BlockTransactionHashIndex, KeyPurpose> outputs) {
super(wallet, blockTransaction.getLabel(), createChildEntries(wallet, inputs, outputs));
super(wallet.isNested() ? wallet.getMasterWallet() : wallet, blockTransaction.getLabel(), createChildEntries(wallet, inputs, outputs));
this.blockTransaction = blockTransaction;
labelProperty().addListener((observable, oldValue, newValue) -> {
@ -169,7 +169,9 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TransactionEntry that = (TransactionEntry) o;
return getWallet().equals(that.getWallet()) && blockTransaction.equals(that.blockTransaction);
//Note we check children count only if both are non-zero because we need to match TransactionEntry objects without children for WalletEntryLabelsChangedEvent
return super.equals(that) && blockTransaction.equals(that.blockTransaction)
&& (getChildren().isEmpty() || that.getChildren().isEmpty() || getChildren().size() == that.getChildren().size());
}
@Override
@ -244,7 +246,7 @@ public class TransactionEntry extends Entry implements Comparable<TransactionEnt
@Subscribe
public void blockHeightChanged(WalletBlockHeightChangedEvent event) {
if(getWallet().equals(event.getWallet())) {
if(event.getWallet().equals(getWallet())) {
setConfirmations(calculateConfirmations());
if(!isFullyConfirming()) {

6
src/main/java/com/sparrowwallet/sparrow/wallet/TransactionsController.java

@ -182,7 +182,7 @@ public class TransactionsController extends WalletFormController implements Init
//Will automatically update transactionsTable transactions and recalculate balances
walletTransactionsEntry.updateTransactions();
transactionsTable.updateHistory(event.getHistoryChangedNodes());
transactionsTable.updateHistory();
balance.setValue(walletTransactionsEntry.getBalance());
mempoolBalance.setValue(walletTransactionsEntry.getMempoolBalance());
balanceChart.update(walletTransactionsEntry);
@ -192,7 +192,7 @@ public class TransactionsController extends WalletFormController implements Init
@Subscribe
public void walletEntryLabelChanged(WalletEntryLabelsChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
if(event.fromThisOrNested(walletForm.getWallet())) {
for(Entry entry : event.getEntries()) {
transactionsTable.updateLabel(entry);
}
@ -270,7 +270,7 @@ public class TransactionsController extends WalletFormController implements Init
@Subscribe
public void includeMempoolOutputsChangedEvent(IncludeMempoolOutputsChangedEvent event) {
walletHistoryChanged(new WalletHistoryChangedEvent(getWalletForm().getWallet(), getWalletForm().getStorage(), Collections.emptyList()));
walletHistoryChanged(new WalletHistoryChangedEvent(getWalletForm().getWallet(), getWalletForm().getStorage(), Collections.emptyList(), Collections.emptyList()));
}
@Subscribe

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

@ -52,7 +52,7 @@ public class UtxoEntry extends HashIndexEntry {
}
public Address getAddress() {
return getWallet().getAddress(node);
return node.getAddress();
}
public WalletNode getNode() {
@ -60,7 +60,7 @@ public class UtxoEntry extends HashIndexEntry {
}
public String getOutputDescriptor() {
return getWallet().getOutputDescriptor(node);
return node.getOutputDescriptor();
}
/**

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

@ -283,7 +283,7 @@ public class UtxosController extends WalletFormController implements Initializab
} finally {
wallet.encrypt(key);
for(Wallet childWallet : wallet.getChildWallets()) {
if(!childWallet.isEncrypted()) {
if(!childWallet.isNested() && !childWallet.isEncrypted()) {
childWallet.encrypt(key);
}
}
@ -340,13 +340,13 @@ public class UtxosController extends WalletFormController implements Initializab
}
WalletNode badbankNode = badbankWallet.getFreshNode(KeyPurpose.RECEIVE);
Payment changePayment = new Payment(badbankWallet.getAddress(badbankNode), "Badbank Change", tx0Preview.getChangeValue(), false);
Payment changePayment = new Payment(badbankNode.getAddress(), "Badbank Change", tx0Preview.getChangeValue(), false);
payments.add(changePayment);
WalletNode premixNode = null;
for(int i = 0; i < tx0Preview.getNbPremix(); i++) {
premixNode = premixWallet.getFreshNode(KeyPurpose.RECEIVE, premixNode);
Address premixAddress = premixWallet.getAddress(premixNode);
Address premixAddress = premixNode.getAddress();
payments.add(new Payment(premixAddress, "Premix #" + i, tx0Preview.getPremixValue(), false));
}
@ -509,14 +509,14 @@ public class UtxosController extends WalletFormController implements Initializab
}
updateFields(walletUtxosEntry);
utxosTable.updateHistory(event.getHistoryChangedNodes());
utxosTable.updateHistory();
utxosChart.update(walletUtxosEntry);
}
}
@Subscribe
public void walletEntryLabelChanged(WalletEntryLabelsChangedEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
if(event.fromThisOrNested(walletForm.getWallet())) {
for(Entry entry : event.getEntries()) {
utxosTable.updateLabel(entry);
}
@ -565,7 +565,7 @@ public class UtxosController extends WalletFormController implements Initializab
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
if(event.fromThisOrNested(getWalletForm().getWallet())) {
utxosTable.refresh();
updateButtons(Config.get().getBitcoinUnit());
}

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

@ -34,6 +34,8 @@ public class WalletForm {
private final Storage storage;
protected Wallet wallet;
private final List<WalletForm> nestedWalletForms = new ArrayList<>();
private WalletTransactionsEntry walletTransactionsEntry;
private WalletUtxosEntry walletUtxosEntry;
private final List<NodeEntry> accountEntries = new ArrayList<>();
@ -85,6 +87,10 @@ public class WalletForm {
throw new UnsupportedOperationException("Only SettingsWalletForm supports setWallet");
}
public List<WalletForm> getNestedWalletForms() {
return nestedWalletForms;
}
public void revert() {
throw new UnsupportedOperationException("Only SettingsWalletForm supports revert");
}
@ -117,10 +123,14 @@ public class WalletForm {
}
public void refreshHistory(Integer blockHeight) {
refreshHistory(blockHeight, null);
refreshHistory(blockHeight, null, null);
}
public void refreshHistory(Integer blockHeight, Set<WalletNode> nodes) {
refreshHistory(blockHeight, null, nodes);
}
public void refreshHistory(Integer blockHeight, List<Wallet> filterToWallets, Set<WalletNode> nodes) {
Wallet previousWallet = wallet.copy();
if(wallet.isValid() && AppServices.isConnected()) {
if(log.isDebugEnabled()) {
@ -128,12 +138,12 @@ public class WalletForm {
}
Set<WalletNode> walletTransactionNodes = getWalletTransactionNodes(nodes);
if(walletTransactionNodes == null || !walletTransactionNodes.isEmpty()) {
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, walletTransactionNodes);
if(!wallet.isNested() && (walletTransactionNodes == null || !walletTransactionNodes.isEmpty())) {
ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, filterToWallets, walletTransactionNodes);
historyService.setOnSucceeded(workerStateEvent -> {
if(historyService.getValue()) {
EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
updateWallet(blockHeight, previousWallet);
updateWallets(blockHeight, previousWallet);
}
});
historyService.setOnFailed(workerStateEvent -> {
@ -175,8 +185,8 @@ public class WalletForm {
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
}
}
EventManager.get().post(new ChildWalletAddedEvent(storage, wallet, addedWallet));
}
EventManager.get().post(new ChildWalletsAddedEvent(storage, wallet, addedWallets));
});
paymentCodesService.setOnFailed(failedEvent -> {
log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException());
@ -186,33 +196,51 @@ public class WalletForm {
}
}
private void updateWallet(Integer blockHeight, Wallet previousWallet) {
private void updateWallets(Integer blockHeight, Wallet previousWallet) {
List<WalletNode> nestedHistoryChangedNodes = new ArrayList<>();
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isNested()) {
Wallet previousChildWallet = previousWallet.getChildWallet(childWallet.getName());
if(previousChildWallet != null) {
nestedHistoryChangedNodes.addAll(updateWallet(blockHeight, childWallet, previousChildWallet, Collections.emptyList()));
}
}
}
updateWallet(blockHeight, wallet, previousWallet, nestedHistoryChangedNodes);
}
private List<WalletNode> updateWallet(Integer blockHeight, Wallet currentWallet, Wallet previousWallet, List<WalletNode> nestedHistoryChangedNodes) {
if(blockHeight != null) {
wallet.setStoredBlockHeight(blockHeight);
currentWallet.setStoredBlockHeight(blockHeight);
}
notifyIfChanged(blockHeight, previousWallet);
return notifyIfChanged(blockHeight, currentWallet, previousWallet, nestedHistoryChangedNodes);
}
private void notifyIfChanged(Integer blockHeight, Wallet previousWallet) {
private List<WalletNode> notifyIfChanged(Integer blockHeight, Wallet currentWallet, Wallet previousWallet, List<WalletNode> nestedHistoryChangedNodes) {
List<WalletNode> historyChangedNodes = new ArrayList<>();
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), wallet.getNode(KeyPurpose.RECEIVE).getChildren()));
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.CHANGE).getChildren(), wallet.getNode(KeyPurpose.CHANGE).getChildren()));
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.RECEIVE).getChildren(), currentWallet.getNode(KeyPurpose.RECEIVE).getChildren()));
historyChangedNodes.addAll(getHistoryChangedNodes(previousWallet.getNode(KeyPurpose.CHANGE).getChildren(), currentWallet.getNode(KeyPurpose.CHANGE).getChildren()));
boolean changed = false;
if(!historyChangedNodes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(wallet, storage, historyChangedNodes)));
changed = true;
if(!historyChangedNodes.isEmpty() || !nestedHistoryChangedNodes.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletHistoryChangedEvent(currentWallet, storage, historyChangedNodes, nestedHistoryChangedNodes)));
if(!historyChangedNodes.isEmpty()) {
changed = true;
}
}
if(blockHeight != null && !blockHeight.equals(previousWallet.getStoredBlockHeight())) {
Platform.runLater(() -> EventManager.get().post(new WalletBlockHeightChangedEvent(wallet, blockHeight)));
Platform.runLater(() -> EventManager.get().post(new WalletBlockHeightChangedEvent(currentWallet, blockHeight)));
changed = true;
}
if(changed) {
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(wallet)));
Platform.runLater(() -> EventManager.get().post(new WalletDataChangedEvent(currentWallet)));
}
return historyChangedNodes;
}
private List<WalletNode> getHistoryChangedNodes(Set<WalletNode> previousNodes, Set<WalletNode> currentNodes) {
@ -390,7 +418,7 @@ public class WalletForm {
public void newBlock(NewBlockEvent event) {
//Check if wallet is valid to avoid saving wallets in initial setup
if(wallet.isValid()) {
updateWallet(event.getHeight(), wallet.copy());
updateWallet(event.getHeight(), wallet, wallet.copy(), Collections.emptyList());
}
}
@ -401,7 +429,7 @@ public class WalletForm {
@Subscribe
public void walletNodeHistoryChanged(WalletNodeHistoryChangedEvent event) {
if(wallet.isValid()) {
if(wallet.isValid() && !wallet.isNested()) {
if(transactionMempoolService != null) {
transactionMempoolService.cancel();
}
@ -443,7 +471,7 @@ public class WalletForm {
@Subscribe
public void walletLabelsChanged(WalletEntryLabelsChangedEvent event) {
if(event.getWallet() == wallet) {
if(event.toThisOrNested(wallet)) {
Map<Entry, Entry> labelChangedEntries = new LinkedHashMap<>();
for(Entry entry : event.getEntries()) {
if(entry.getLabel() != null && !entry.getLabel().isEmpty()) {
@ -456,8 +484,7 @@ public class WalletForm {
receivedRef.setLabel(entry.getLabel() + (keyPurpose == KeyPurpose.CHANGE ? " (change)" : " (received)"));
labelChangedEntries.put(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, keyPurpose), entry);
}
//Avoid recursive changes to address labels - only initial transaction label changes can change address labels
if((childNode.getLabel() == null || childNode.getLabel().isEmpty()) && event.getSource(entry) == null) {
if((childNode.getLabel() == null || childNode.getLabel().isEmpty())) {
childNode.setLabel(entry.getLabel());
labelChangedEntries.put(new NodeEntry(event.getWallet(), childNode), entry);
}
@ -481,7 +508,8 @@ public class WalletForm {
}
if(entry instanceof HashIndexEntry hashIndexEntry) {
BlockTransaction blockTransaction = hashIndexEntry.getBlockTransaction();
if(blockTransaction.getLabel() == null || blockTransaction.getLabel().isEmpty()) {
//Avoid recursive changes from hashIndexEntries
if((blockTransaction.getLabel() == null || blockTransaction.getLabel().isEmpty()) && event.getSource(entry) == null) {
blockTransaction.setLabel(entry.getLabel());
labelChangedEntries.put(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()), entry);
}
@ -589,6 +617,9 @@ public class WalletForm {
AppServices.clearTransactionHistoryCache(wallet);
}
EventManager.get().unregister(this);
for(WalletForm nestedWalletForm : nestedWalletForms) {
EventManager.get().unregister(nestedWalletForm);
}
}
}
}
@ -598,4 +629,14 @@ public class WalletForm {
accountEntries.clear();
EventManager.get().post(new WalletAddressesStatusEvent(wallet));
}
@Subscribe
public void childWalletsAdded(ChildWalletsAddedEvent event) {
if(event.getWallet() == wallet) {
List<Wallet> nestedWallets = event.getChildWallets().stream().filter(Wallet::isNested).collect(Collectors.toList());
if(!nestedWallets.isEmpty()) {
Platform.runLater(() -> refreshHistory(AppServices.getCurrentBlockHeight(), nestedWallets, null));
}
}
}
}

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

@ -67,6 +67,9 @@ public class WalletTransactionsEntry extends Entry {
}
public void updateTransactions() {
Map<HashIndex, BlockTransactionHashIndex> walletTxos = getWallet().getWalletTxos().entrySet().stream()
.collect(Collectors.toUnmodifiableMap(entry -> new HashIndex(entry.getKey().getHash(), entry.getKey().getIndex()), Map.Entry::getKey));
List<Entry> current = getWalletTransactions(getWallet()).stream().map(WalletTransaction::getTransactionEntry).collect(Collectors.toList());
List<Entry> previous = new ArrayList<>(getChildren());
@ -80,8 +83,6 @@ public class WalletTransactionsEntry extends Entry {
calculateBalances(true);
Map<HashIndex, BlockTransactionHashIndex> walletTxos = getWallet().getWalletTxos().entrySet().stream()
.collect(Collectors.toUnmodifiableMap(entry -> new HashIndex(entry.getKey().getHash(), entry.getKey().getIndex()), Map.Entry::getKey));
List<Entry> entriesComplete = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).isComplete(walletTxos)).collect(Collectors.toList());
if(!entriesComplete.isEmpty()) {
EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), entriesAdded.stream().map(entry -> (TransactionEntry)entry).collect(Collectors.toList())));
@ -104,6 +105,14 @@ public class WalletTransactionsEntry extends Entry {
getWalletTransactions(wallet, walletTransactionMap, wallet.getNode(keyPurpose));
}
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isNested()) {
for(KeyPurpose keyPurpose : childWallet.getWalletKeyPurposes()) {
getWalletTransactions(childWallet, walletTransactionMap, childWallet.getNode(keyPurpose));
}
}
}
List<WalletTransaction> walletTransactions = new ArrayList<>(walletTransactionMap.values());
Collections.sort(walletTransactions);
return walletTransactions;
@ -114,7 +123,7 @@ public class WalletTransactionsEntry extends Entry {
List<WalletNode> childNodes = new ArrayList<>(purposeNode.getChildren());
for(WalletNode addressNode : childNodes) {
for(BlockTransactionHashIndex hashIndex : addressNode.getTransactionOutputs()) {
BlockTransaction inputTx = wallet.getTransactions().get(hashIndex.getHash());
BlockTransaction inputTx = wallet.getWalletTransaction(hashIndex.getHash());
//A null inputTx here means the wallet is still updating - ignore as the WalletHistoryChangedEvent will run this again
if(inputTx != null) {
WalletTransaction inputWalletTx = walletTransactionMap.get(inputTx);
@ -125,7 +134,7 @@ public class WalletTransactionsEntry extends Entry {
inputWalletTx.incoming.put(hashIndex, keyPurpose);
if(hashIndex.getSpentBy() != null) {
BlockTransaction outputTx = wallet.getTransactions().get(hashIndex.getSpentBy().getHash());
BlockTransaction outputTx = wallet.getWalletTransaction(hashIndex.getSpentBy().getHash());
if(outputTx != null) {
WalletTransaction outputWalletTx = walletTransactionMap.get(outputTx);
if(outputWalletTx == null) {

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

@ -10,7 +10,7 @@ import java.util.stream.Collectors;
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()));
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
calculateDuplicates();
updateMixProgress();
}
@ -62,7 +62,7 @@ public class WalletUtxosEntry extends Entry {
}
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> current = getWallet().getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList());
List<Entry> previous = new ArrayList<>(getChildren());
List<Entry> entriesAdded = new ArrayList<>(current);

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

@ -237,7 +237,7 @@ public class Whirlpool {
}
public MixProgress getMixProgress(BlockTransactionHashIndex utxo) {
if(whirlpoolWalletService.whirlpoolWallet() == null) {
if(whirlpoolWalletService.whirlpoolWallet() == null || utxo.getStatus() == Status.FROZEN) {
return null;
}
@ -409,7 +409,7 @@ public class Whirlpool {
return StandardAccount.ACCOUNT_0;
}
public static UnspentOutput getUnspentOutput(Wallet wallet, WalletNode node, BlockTransaction blockTransaction, int index) {
public static UnspentOutput getUnspentOutput(WalletNode node, BlockTransaction blockTransaction, int index) {
TransactionOutput txOutput = blockTransaction.getTransaction().getOutputs().get(index);
UnspentOutput out = new UnspentOutput();
@ -431,6 +431,7 @@ public class Whirlpool {
out.confirmations = blockTransaction.getConfirmations(AppServices.getCurrentBlockHeight());
}
Wallet wallet = node.getWallet().isBip47() ? node.getWallet().getMasterWallet() : node.getWallet();
if(wallet.getKeystores().size() != 1) {
throw new IllegalStateException("Cannot mix outputs from a wallet with multiple keystores");
}
@ -558,7 +559,7 @@ public class Whirlpool {
private WalletNode getReceiveNode(MixSuccessEvent e, WalletUtxo walletUtxo) {
for(WalletNode walletNode : walletUtxo.wallet.getNode(KeyPurpose.RECEIVE).getChildren()) {
if(walletUtxo.wallet.getAddress(walletNode).toString().equals(e.getMixProgress().getDestination().getAddress())) {
if(walletNode.getAddress().toString().equals(e.getMixProgress().getDestination().getAddress())) {
return walletNode;
}
}
@ -638,12 +639,10 @@ public class Whirlpool {
public static class Tx0PreviewsService extends Service<Tx0Previews> {
private final Whirlpool whirlpool;
private final Wallet wallet;
private final List<UtxoEntry> utxoEntries;
public Tx0PreviewsService(Whirlpool whirlpool, Wallet wallet, List<UtxoEntry> utxoEntries) {
public Tx0PreviewsService(Whirlpool whirlpool, List<UtxoEntry> utxoEntries) {
this.whirlpool = whirlpool;
this.wallet = wallet;
this.utxoEntries = utxoEntries;
}
@ -654,7 +653,7 @@ public class Whirlpool {
updateProgress(-1, 1);
updateMessage("Fetching premix preview...");
Collection<UnspentOutput> utxos = utxoEntries.stream().map(utxoEntry -> Whirlpool.getUnspentOutput(wallet, utxoEntry.getNode(), utxoEntry.getBlockTransaction(), (int)utxoEntry.getHashIndex().getIndex())).collect(Collectors.toList());
Collection<UnspentOutput> utxos = utxoEntries.stream().map(utxoEntry -> Whirlpool.getUnspentOutput(utxoEntry.getNode(), utxoEntry.getBlockTransaction(), (int)utxoEntry.getHashIndex().getIndex())).collect(Collectors.toList());
return whirlpool.getTx0Previews(utxos);
}
};

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

@ -318,7 +318,7 @@ public class WhirlpoolController {
whirlpool.setScode(mixConfig.getScode());
whirlpool.setTx0FeeTarget(FEE_TARGETS.get(premixPriority.valueProperty().intValue()));
Whirlpool.Tx0PreviewsService tx0PreviewsService = new Whirlpool.Tx0PreviewsService(whirlpool, wallet, utxoEntries);
Whirlpool.Tx0PreviewsService tx0PreviewsService = new Whirlpool.Tx0PreviewsService(whirlpool, utxoEntries);
tx0PreviewsService.setOnRunning(workerStateEvent -> {
nbOutputsBox.setVisible(true);
nbOutputsLoading.setText("Calculating...");

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

@ -12,9 +12,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.TorService;
import com.sparrowwallet.sparrow.soroban.Soroban;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
@ -182,7 +180,7 @@ public class WhirlpoolServices {
for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) {
if(decryptedWallet.getChildWallet(whirlpoolAccount) == null) {
Wallet childWallet = decryptedWallet.addChildWallet(whirlpoolAccount);
EventManager.get().post(new ChildWalletAddedEvent(storage, decryptedWallet, childWallet));
EventManager.get().post(new ChildWalletsAddedEvent(storage, decryptedWallet, childWallet));
}
}
}

64
src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowDataSource.java

@ -1,15 +1,22 @@
package com.sparrowwallet.sparrow.whirlpool.dataSource;
import com.google.common.eventbus.Subscribe;
import com.samourai.wallet.api.backend.MinerFee;
import com.samourai.wallet.api.backend.MinerFeeTarget;
import com.samourai.wallet.api.backend.beans.UnspentOutput;
import com.samourai.wallet.api.backend.beans.WalletResponse;
import com.samourai.wallet.hd.HD_Wallet;
import com.samourai.whirlpool.client.tx0.Tx0ParamService;
import com.samourai.whirlpool.client.wallet.WhirlpoolWallet;
import com.samourai.whirlpool.client.wallet.beans.WhirlpoolUtxo;
import com.samourai.whirlpool.client.wallet.data.chain.ChainSupplier;
import com.samourai.whirlpool.client.wallet.data.dataPersister.DataPersister;
import com.samourai.whirlpool.client.wallet.data.dataSource.WalletResponseDataSource;
import com.samourai.whirlpool.client.wallet.data.minerFee.MinerFeeSupplier;
import com.samourai.whirlpool.client.wallet.data.pool.PoolSupplier;
import com.samourai.whirlpool.client.wallet.data.utxo.BasicUtxoSupplier;
import com.samourai.whirlpool.client.wallet.data.utxo.UtxoData;
import com.samourai.whirlpool.client.wallet.data.utxoConfig.UtxoConfigSupplier;
import com.samourai.whirlpool.client.wallet.data.wallet.WalletSupplier;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Network;
@ -79,8 +86,9 @@ public class SparrowDataSource extends WalletResponseDataSource {
continue;
}
allTransactions.putAll(wallet.getTransactions());
wallet.getTransactions().keySet().forEach(txid -> allTransactionsZpubs.put(txid, zpub));
Map<Sha256Hash, BlockTransaction> walletTransactions = wallet.getWalletTransactions();
allTransactions.putAll(walletTransactions);
walletTransactions.keySet().forEach(txid -> allTransactionsZpubs.put(txid, zpub));
if(wallet.getStoredBlockHeight() != null) {
storedBlockHeight = Math.max(storedBlockHeight, wallet.getStoredBlockHeight());
}
@ -93,13 +101,13 @@ public class SparrowDataSource extends WalletResponseDataSource {
address.account_index = wallet.getMixConfig() != null ? Math.max(receiveIndex, wallet.getMixConfig().getReceiveIndex()) : receiveIndex;
int changeIndex = wallet.getNode(KeyPurpose.CHANGE).getHighestUsedIndex() == null ? 0 : wallet.getNode(KeyPurpose.CHANGE).getHighestUsedIndex() + 1;
address.change_index = wallet.getMixConfig() != null ? Math.max(changeIndex, wallet.getMixConfig().getChangeIndex()) : changeIndex;
address.n_tx = wallet.getTransactions().size();
address.n_tx = walletTransactions.size();
addresses.add(address);
for(Map.Entry<BlockTransactionHashIndex, WalletNode> utxo : wallet.getWalletUtxos().entrySet()) {
BlockTransaction blockTransaction = wallet.getTransactions().get(utxo.getKey().getHash());
BlockTransaction blockTransaction = wallet.getWalletTransaction(utxo.getKey().getHash());
if(blockTransaction != null && utxo.getKey().getStatus() != Status.FROZEN) {
unspentOutputs.add(Whirlpool.getUnspentOutput(wallet, utxo.getValue(), blockTransaction, (int)utxo.getKey().getIndex()));
unspentOutputs.add(Whirlpool.getUnspentOutput(utxo.getValue(), blockTransaction, (int)utxo.getKey().getIndex()));
}
}
}
@ -164,6 +172,50 @@ public class SparrowDataSource extends WalletResponseDataSource {
return walletResponse;
}
@Override
protected BasicUtxoSupplier computeUtxoSupplier(WhirlpoolWallet whirlpoolWallet, WalletSupplier walletSupplier, UtxoConfigSupplier utxoConfigSupplier, ChainSupplier chainSupplier, PoolSupplier poolSupplier, Tx0ParamService tx0ParamService) throws Exception {
return new BasicUtxoSupplier(
walletSupplier,
utxoConfigSupplier,
chainSupplier,
poolSupplier,
tx0ParamService) {
@Override
public void refresh() throws Exception {
SparrowDataSource.this.refresh();
}
@Override
protected void onUtxoChanges(UtxoData utxoData) {
super.onUtxoChanges(utxoData);
whirlpoolWallet.onUtxoChanges(utxoData);
}
@Override
protected byte[] _getPrivKeyBytes(WhirlpoolUtxo whirlpoolUtxo) {
UnspentOutput utxo = whirlpoolUtxo.getUtxo();
Wallet wallet = getWallet(utxo.xpub.m);
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getWalletUtxos();
WalletNode node = walletUtxos.entrySet().stream()
.filter(entry -> entry.getKey().getHash().equals(Sha256Hash.wrap(utxo.tx_hash)) && entry.getKey().getIndex() == utxo.tx_output_n)
.map(Map.Entry::getValue)
.findFirst()
.orElseThrow(() -> new IllegalStateException("Cannot find UTXO " + utxo));
if(node.getWallet().isBip47()) {
try {
Keystore keystore = node.getWallet().getKeystores().get(0);
return keystore.getKey(node).getPrivKeyBytes();
} catch(Exception e) {
log.error("Error getting private key", e);
}
}
return null;
}
};
}
@Override
public void pushTx(String txHex) throws Exception {
Transaction transaction = new Transaction(Utils.hexToBytes(txHex));

3
src/main/java/com/sparrowwallet/sparrow/whirlpool/dataSource/SparrowPostmixHandler.java

@ -39,7 +39,8 @@ public class SparrowPostmixHandler implements IPostmixHandler {
int index = Math.max(getIndexHandler().getAndIncrementUnconfirmed(), startIndex);
// address
Address address = wallet.getAddress(new WalletNode(keyPurpose, index));
WalletNode node = new WalletNode(wallet, keyPurpose, index);
Address address = node.getAddress();
String path = XPubUtil.getInstance().getPath(index, keyPurpose.getPathIndex().num());
log.info("Mixing to external xPub -> receiveAddress=" + address + ", path=" + path);

2
src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java

@ -52,7 +52,7 @@ public class StorageTest extends IoTest {
Assert.assertEquals("xpub6BrhGFTWPd3DXo8s2BPxHHzCmBCyj8QvamcEUaq8EDwnwXpvvcU9LzpJqENHcqHkqwTn2vPhynGVoEqj3PAB3NxnYZrvCsSfoCniJKaggdy", wallet.getKeystores().get(0).getExtendedPublicKey().toString());
Assert.assertEquals("af6ebd81714c301c3a71fe11a7a9c99ccef4b33d4b36582220767bfa92768a2aa040f88b015b2465f8075a8b9dbf892a7d6e6c49932109f2cbc05ba0bd7f355fbcc34c237f71be5fb4dd7f8184e44cb0", Utils.bytesToHex(wallet.getKeystores().get(0).getSeed().getEncryptedData().getEncryptedBytes()));
Assert.assertNull(wallet.getKeystores().get(0).getSeed().getMnemonicCode());
Assert.assertEquals("bc1q2mkrttcuzryrdyn9vtu3nfnt3jlngwn476ktus", wallet.getAddress(wallet.getFreshNode(KeyPurpose.RECEIVE)).toString());
Assert.assertEquals("bc1q2mkrttcuzryrdyn9vtu3nfnt3jlngwn476ktus", wallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString());
}
@Test

Loading…
Cancel
Save