From a765e07c103f3b8f2e7041ee715b5959d5479600 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Wed, 16 Mar 2022 16:37:08 +0200 Subject: [PATCH] support linking and sending to payment codes without paynym.is --- .../sparrowwallet/sparrow/AppController.java | 5 +- .../sparrowwallet/sparrow/AppServices.java | 7 + .../sparrow/control/EntryCell.java | 27 +- .../sparrow/control/PayNymAvatar.java | 4 +- .../sparrow/control/PayNymCell.java | 37 +++ .../control/ServiceProgressDialog.java | 114 +++++++++ .../sparrow/control/WalletLabelDialog.java | 20 +- .../sparrow/event/SpendUtxoEvent.java | 25 +- .../sparrowwallet/sparrow/paynym/PayNym.java | 20 ++ .../sparrow/paynym/PayNymController.java | 74 +++++- .../sparrow/paynym/PayNymDialog.java | 6 + .../sparrow/wallet/PaymentController.java | 96 ++++++-- .../sparrow/wallet/SendController.java | 233 ++++++++++++++++-- .../sparrowwallet/sparrow/paynym/paynym.fxml | 2 +- .../sparrowwallet/sparrow/wallet/payment.css | 3 + .../sparrowwallet/sparrow/wallet/payment.fxml | 2 +- .../sparrowwallet/sparrow/wallet/send.fxml | 5 + 17 files changed, 601 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 27da823d..4b952b0f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -345,7 +345,6 @@ public class AppController implements Initializable { showPayNym.setDisable(true); findMixingPartner.setDisable(true); AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> { - showPayNym.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !getSelectedWalletForm().getWallet().hasPaymentCode() || !newValue); findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue); }); @@ -1989,7 +1988,7 @@ public class AppController implements Initializable { exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid() || walletTabData.getWalletForm().isLocked()); showLoadingLog.setDisable(false); showTxHex.setDisable(true); - showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get()); + showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode()); findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get()); } } @@ -2015,7 +2014,7 @@ public class AppController implements Initializable { if(selectedWalletForm != null) { if(selectedWalletForm.getWalletId().equals(event.getWalletId())) { exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked()); - showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get()); + showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode()); findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get()); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index e85bccd5..d7c719a1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -710,11 +710,18 @@ public class AppServices { } public static Optional showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) { + return showAlertDialog(title, content, alertType, null, buttons); + } + + public static Optional showAlertDialog(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) { Alert alert = new Alert(alertType, content, buttons); setStageIcon(alert.getDialogPane().getScene().getWindow()); alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); alert.setTitle(title); alert.setHeaderText(title); + if(graphic != null) { + alert.setGraphic(graphic); + } Pattern linkPattern = Pattern.compile("\\[(http.+)]"); Matcher matcher = linkPattern.matcher(content); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 71bf0fc9..7c8f6505 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow.control; import com.sparrowwallet.drongo.KeyPurpose; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.address.Address; -import com.sparrowwallet.drongo.protocol.ScriptType; -import com.sparrowwallet.drongo.protocol.Transaction; -import com.sparrowwallet.drongo.protocol.TransactionInput; -import com.sparrowwallet.drongo.protocol.TransactionOutput; +import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; @@ -242,20 +239,36 @@ public class EntryCell extends TreeTableCell { label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)"; } - return new Payment(txOutput.getScript().getToAddresses()[0], label, txOutput.getValue(), false); + if(txOutput.getScript().getToAddress() != null) { + return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), false); + } + + return null; } catch(Exception e) { log.error("Error creating RBF payment", e); return null; } }).filter(Objects::nonNull).collect(Collectors.toList()); + List opReturns = externalOutputs.stream().map(txOutput -> { + List scriptChunks = txOutput.getScript().getChunks(); + if(scriptChunks.size() != 2 || scriptChunks.get(0).getOpcode() != ScriptOpCodes.OP_RETURN) { + return null; + } + if(scriptChunks.get(1).getData() != null) { + return scriptChunks.get(1).getData(); + } + + return null; + }).filter(Objects::nonNull).collect(Collectors.toList()); + if(payments.isEmpty()) { AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction, check log for details"); return; } EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos)); - Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true))); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true))); } private static Double getMaxFeeRate() { @@ -287,7 +300,7 @@ public class EntryCell extends TreeTableCell { 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))); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false))); } private static boolean canSignMessage(WalletNode walletNode) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PayNymAvatar.java b/src/main/java/com/sparrowwallet/sparrow/control/PayNymAvatar.java index 1f53ccc1..0d48dbde 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PayNymAvatar.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PayNymAvatar.java @@ -39,7 +39,7 @@ public class PayNymAvatar extends StackPane { String cacheId = getCacheId(paymentCode, getPrefWidth()); if(paymentCodeCache.containsKey(cacheId)) { setImage(paymentCodeCache.get(cacheId)); - } else { + } else if(AppServices.isConnected()) { PayNymAvatarService payNymAvatarService = new PayNymAvatarService(paymentCode, getPrefWidth()); payNymAvatarService.setOnRunning(runningEvent -> { getChildren().clear(); @@ -48,7 +48,7 @@ public class PayNymAvatar extends StackPane { setImage(payNymAvatarService.getValue()); }); payNymAvatarService.setOnFailed(failedEvent -> { - log.error("Error", failedEvent.getSource().getException()); + log.debug("Error loading PayNym avatar", failedEvent.getSource().getException()); }); payNymAvatarService.start(); } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java b/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java index d5895fa4..d3c61957 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java @@ -1,5 +1,8 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.WalletLabelChangedEvent; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.paynym.PayNym; import com.sparrowwallet.sparrow.paynym.PayNymController; @@ -10,6 +13,8 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import org.controlsfx.glyphfont.Glyph; +import java.util.Optional; + public class PayNymCell extends ListCell { private final PayNymController payNymController; @@ -83,6 +88,12 @@ public class PayNymCell extends ListCell { setText(null); setGraphic(pane); + + if(payNymController != null && payNym.nymId() == null) { + setContextMenu(new PayNymCellContextMenu(payNym)); + } else { + setContextMenu(null); + } } } @@ -91,4 +102,30 @@ public class PayNymCell extends ListCell { failGlyph.setFontSize(12); return failGlyph; } + + private class PayNymCellContextMenu extends ContextMenu { + public PayNymCellContextMenu(PayNym payNym) { + MenuItem rename = new MenuItem("Rename Contact..."); + rename.setOnAction(event -> { + WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact"); + Optional optLabel = walletLabelDialog.showAndWait(); + if(optLabel.isPresent()) { + int index = getListView().getItems().indexOf(payNym); + for(Wallet childWallet : payNymController.getMasterWallet().getChildWallets()) { + if(childWallet.isBip47() + && childWallet.getKeystores().get(0).getExternalPaymentCode().equals(payNym.paymentCode()) + && (childWallet.getLabel() == null || childWallet.getLabel().startsWith(payNym.nymName()))) { + childWallet.setLabel(optLabel.get() + " " + childWallet.getScriptType().getName()); + EventManager.get().post(new WalletLabelChangedEvent(childWallet)); + } + } + + payNymController.updateFollowing(); + getListView().getSelectionModel().select(index); + } + }); + + getItems().add(rename); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java index 2e05d306..8586bd8e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow.control; import com.sparrowwallet.sparrow.AppServices; +import javafx.beans.property.*; import javafx.concurrent.Worker; import javafx.scene.control.DialogPane; import javafx.scene.image.Image; @@ -22,4 +23,117 @@ public class ServiceProgressDialog extends ProgressDialog { Image image = new Image(imagePath); dialogPane.setGraphic(new ImageView(image)); } + + public static class ProxyWorker implements Worker { + private final ObjectProperty state = new SimpleObjectProperty<>(this, "state", State.READY); + private final StringProperty message = new SimpleStringProperty(this, "message", ""); + private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress", -1); + + public void start() { + state.set(State.SCHEDULED); + } + + public void end() { + state.set(State.SUCCEEDED); + } + + @Override + public State getState() { + return state.get(); + } + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state; + } + + @Override + public Boolean getValue() { + return Boolean.TRUE; + } + + @Override + public ReadOnlyObjectProperty valueProperty() { + return null; + } + + @Override + public Throwable getException() { + return null; + } + + @Override + public ReadOnlyObjectProperty exceptionProperty() { + return null; + } + + @Override + public double getWorkDone() { + return 0; + } + + @Override + public ReadOnlyDoubleProperty workDoneProperty() { + return null; + } + + @Override + public double getTotalWork() { + return 0; + } + + @Override + public ReadOnlyDoubleProperty totalWorkProperty() { + return null; + } + + @Override + public double getProgress() { + return progress.get(); + } + + @Override + public ReadOnlyDoubleProperty progressProperty() { + return progress; + } + + @Override + public boolean isRunning() { + return false; + } + + @Override + public ReadOnlyBooleanProperty runningProperty() { + return null; + } + + @Override + public String getMessage() { + return message.get(); + } + + public void setMessage(String strMessage) { + message.set(strMessage); + } + + @Override + public ReadOnlyStringProperty messageProperty() { + return message; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public ReadOnlyStringProperty titleProperty() { + return null; + } + + @Override + public boolean cancel() { + return false; + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletLabelDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletLabelDialog.java index 6a551785..d7a59c50 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletLabelDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletLabelDialog.java @@ -10,19 +10,26 @@ import javafx.scene.layout.VBox; import org.controlsfx.control.textfield.CustomTextField; import org.controlsfx.control.textfield.TextFields; import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.validation.ValidationResult; import org.controlsfx.validation.ValidationSupport; import org.controlsfx.validation.Validator; import org.controlsfx.validation.decoration.StyleClassValidationDecoration; public class WalletLabelDialog extends Dialog { + private static final int MAX_LABEL_LENGTH = 25; + private final CustomTextField label; public WalletLabelDialog(String initialName) { + this(initialName, "Account"); + } + + public WalletLabelDialog(String initialName, String walletType) { final DialogPane dialogPane = getDialogPane(); AppServices.setStageIcon(dialogPane.getScene().getWindow()); - setTitle("Account Name"); - dialogPane.setHeaderText("Enter a name for this account:"); + setTitle(walletType + " Name"); + dialogPane.setHeaderText("Enter a name for this " + walletType.toLowerCase() + ":"); dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); dialogPane.getButtonTypes().addAll(ButtonType.CANCEL); dialogPane.setPrefWidth(400); @@ -48,17 +55,18 @@ public class WalletLabelDialog extends Dialog { Platform.runLater(() -> { validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); validationSupport.registerValidator(label, Validator.combine( - Validator.createEmptyValidator("Account name is required") + Validator.createEmptyValidator(walletType + " name is required"), + (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Label too long", newValue != null && newValue.length() > MAX_LABEL_LENGTH) )); }); - final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename Account", ButtonBar.ButtonData.OK_DONE); + final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename " + walletType, ButtonBar.ButtonData.OK_DONE); dialogPane.getButtonTypes().addAll(okButtonType); Button okButton = (Button)dialogPane.lookupButton(okButtonType); - BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0, label.textProperty()); + BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0 || label.getText().length() > MAX_LABEL_LENGTH, label.textProperty()); okButton.disableProperty().bind(isInvalid); - label.setPromptText("Account Name"); + label.setPromptText(walletType + " Name"); Platform.runLater(label::requestFocus); setResultConverter(dialogButton -> dialogButton == okButtonType ? label.getText() : null); } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java index 4eb323fd..b0be72fb 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java @@ -1,6 +1,7 @@ package com.sparrowwallet.sparrow.event; import com.samourai.whirlpool.client.whirlpool.beans.Pool; +import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; import com.sparrowwallet.drongo.wallet.Payment; import com.sparrowwallet.drongo.wallet.Wallet; @@ -15,19 +16,21 @@ public class SpendUtxoEvent { private final Long fee; private final boolean includeSpentMempoolOutputs; private final Pool pool; + private final PaymentCode paymentCode; public SpendUtxoEvent(Wallet wallet, List utxos) { - this(wallet, utxos, null, null, false); + this(wallet, utxos, null, null, null, false); } - public SpendUtxoEvent(Wallet wallet, List utxos, List payments, Long fee, boolean includeSpentMempoolOutputs) { + public SpendUtxoEvent(Wallet wallet, List utxos, List payments, List opReturns, Long fee, boolean includeSpentMempoolOutputs) { this.wallet = wallet; this.utxos = utxos; this.payments = payments; - this.opReturns = null; + this.opReturns = opReturns; this.fee = fee; this.includeSpentMempoolOutputs = includeSpentMempoolOutputs; this.pool = null; + this.paymentCode = null; } public SpendUtxoEvent(Wallet wallet, List utxos, List payments, List opReturns, Long fee, Pool pool) { @@ -38,6 +41,18 @@ public class SpendUtxoEvent { this.fee = fee; this.includeSpentMempoolOutputs = false; this.pool = pool; + this.paymentCode = null; + } + + public SpendUtxoEvent(Wallet wallet, List payments, List opReturns, PaymentCode paymentCode) { + this.wallet = wallet; + this.utxos = null; + this.payments = payments; + this.opReturns = opReturns; + this.fee = null; + this.includeSpentMempoolOutputs = false; + this.pool = null; + this.paymentCode = paymentCode; } public Wallet getWallet() { @@ -67,4 +82,8 @@ public class SpendUtxoEvent { public Pool getPool() { return pool; } + + public PaymentCode getPaymentCode() { + return paymentCode; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java index eead287f..7b5eb361 100644 --- a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java +++ b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java @@ -3,9 +3,11 @@ package com.sparrowwallet.sparrow.paynym; import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException; import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Wallet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Collections; import java.util.List; public class PayNym { @@ -74,4 +76,22 @@ public class PayNym { return new PayNym(paymentCode, nymId, nymName, segwit, following, followers); } + + public static PayNym fromWallet(Wallet bip47Wallet) { + if(!bip47Wallet.isBip47()) { + throw new IllegalArgumentException("Not a BIP47 wallet"); + } + + PaymentCode externalPaymentCode = bip47Wallet.getKeystores().get(0).getExternalPaymentCode(); + String nymName = externalPaymentCode.toAbbreviatedString(); + if(bip47Wallet.getLabel() != null) { + String suffix = " " + bip47Wallet.getScriptType().getName(); + if(bip47Wallet.getLabel().endsWith(suffix)) { + nymName = bip47Wallet.getLabel().substring(0, bip47Wallet.getLabel().length() - suffix.length()); + } + } + + boolean segwit = bip47Wallet.getScriptType() != ScriptType.P2PKH; + return new PayNym(externalPaymentCode, null, nymName, segwit, Collections.emptyList(), Collections.emptyList()); + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java index 783b087e..37d77ea1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java +++ b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java @@ -18,10 +18,7 @@ import com.sparrowwallet.sparrow.net.ElectrumServer; import com.sparrowwallet.sparrow.wallet.Entry; import com.sparrowwallet.sparrow.wallet.TransactionEntry; import javafx.application.Platform; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; +import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.ActionEvent; @@ -34,18 +31,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.*; +import java.util.function.Function; import java.util.function.UnaryOperator; import java.util.regex.Pattern; +import java.util.stream.Collectors; import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; +import static com.sparrowwallet.sparrow.wallet.PaymentController.MINIMUM_P2PKH_OUTPUT_SATS; public class PayNymController { private static final Logger log = LoggerFactory.getLogger(PayNymController.class); public static final Pattern PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]"); - private static final long MINIMUM_P2PKH_OUTPUT_SATS = 546L; - private String walletId; private boolean selectLinkedOnly; private PayNym walletPayNym; @@ -65,6 +63,9 @@ public class PayNymController { @FXML private CopyableTextField searchPayNyms; + @FXML + private Button searchPayNymsScan; + @FXML private ProgressIndicator findPayNym; @@ -83,6 +84,8 @@ public class PayNymController { private final Map notificationTransactions = new HashMap<>(); + private final BooleanProperty closeProperty = new SimpleBooleanProperty(false); + public void initializeView(String walletId, boolean selectLinkedOnly) { this.walletId = walletId; this.selectLinkedOnly = selectLinkedOnly; @@ -90,6 +93,7 @@ public class PayNymController { payNymName.managedProperty().bind(payNymName.visibleProperty()); payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty()); payNymRetrieve.visibleProperty().bind(payNymName.visibleProperty().not()); + payNymRetrieve.setDisable(!AppServices.isConnected()); retrievePayNymProgress.managedProperty().bind(retrievePayNymProgress.visibleProperty()); retrievePayNymProgress.maxHeightProperty().bind(payNymName.heightProperty()); @@ -132,6 +136,8 @@ public class PayNymController { return change; }; + searchPayNymsScan.disableProperty().bind(searchPayNyms.disableProperty()); + searchPayNyms.setDisable(true); searchPayNyms.setTextFormatter(new TextFormatter<>(paymentCodeFilter)); searchPayNyms.addEventFilter(KeyEvent.ANY, event -> { if(event.getCode() == KeyCode.ENTER) { @@ -158,10 +164,11 @@ public class PayNymController { followersList.setSelectionModel(new NoSelectionModel<>()); followersList.setFocusTraversable(false); - if(Config.get().isUsePayNym() && masterWallet.hasPaymentCode()) { + if(Config.get().isUsePayNym() && AppServices.isConnected() && masterWallet.hasPaymentCode()) { refresh(); } else { payNymName.setVisible(false); + updateFollowing(); } } @@ -174,12 +181,13 @@ public class PayNymController { AppServices.getPayNymService().getPayNym(getMasterWallet().getPaymentCode().toString()).subscribe(payNym -> { retrievePayNymProgress.setVisible(false); walletPayNym = payNym; + searchPayNyms.setDisable(false); payNymName.setText(payNym.nymName()); paymentCode.setPaymentCode(payNym.paymentCode()); payNymAvatar.setPaymentCode(payNym.paymentCode()); followingList.setUserData(null); followingList.setPlaceholder(new Label("No contacts")); - followingList.setItems(FXCollections.observableList(payNym.following())); + updateFollowing(); followersList.setPlaceholder(new Label("No followers")); followersList.setItems(FXCollections.observableList(payNym.followers())); Platform.runLater(() -> addWalletIfNotificationTransactionPresent(payNym.following())); @@ -187,6 +195,7 @@ public class PayNymController { retrievePayNymProgress.setVisible(false); if(error.getMessage().endsWith("404")) { payNymName.setVisible(false); + updateFollowing(); } else { log.error("Error retrieving PayNym", error); Optional optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK); @@ -194,6 +203,7 @@ public class PayNymController { refresh(); } else { payNymName.setVisible(false); + updateFollowing(); } } }); @@ -202,7 +212,7 @@ public class PayNymController { private void resetFollowing() { if(followingList.getUserData() != null) { followingList.setUserData(null); - followingList.setItems(FXCollections.observableList(walletPayNym.following())); + updateFollowing(); } } @@ -300,6 +310,33 @@ public class PayNymController { return getMasterWallet().getChildWallet(externalPaymentCode, payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH) != null; } + public void updateFollowing() { + List followingPayNyms = new ArrayList<>(); + if(walletPayNym != null) { + followingPayNyms.addAll(walletPayNym.following()); + } + + Map followingPayNymMap = followingPayNyms.stream().collect(Collectors.toMap(PayNym::paymentCode, Function.identity())); + followingPayNyms.addAll(getExistingWalletPayNyms(followingPayNymMap)); + followingList.setItems(FXCollections.observableList(followingPayNyms)); + } + + private List getExistingWalletPayNyms(Map followingPayNymMap) { + Map existingPayNyms = new LinkedHashMap<>(); + List childWallets = new ArrayList<>(getMasterWallet().getChildWallets()); + childWallets.sort(Comparator.comparingInt(o -> -o.getScriptType().ordinal())); + for(Wallet childWallet : childWallets) { + if(childWallet.isBip47()) { + PaymentCode externalPaymentCode = childWallet.getKeystores().get(0).getExternalPaymentCode(); + if(!existingPayNyms.containsKey(externalPaymentCode) && !followingPayNymMap.containsKey(externalPaymentCode)) { + existingPayNyms.put(externalPaymentCode, PayNym.fromWallet(childWallet)); + } + } + } + + return new ArrayList<>(existingPayNyms.values()); + } + private void addWalletIfNotificationTransactionPresent(List following) { Map unlinkedPayNyms = new HashMap<>(); Map unlinkedNotifications = new HashMap<>(); @@ -397,11 +434,20 @@ public class PayNymController { } public void linkPayNym(PayNym payNym) { + ButtonType previewType = new ButtonType("Preview", ButtonBar.ButtonData.LEFT); + ButtonType sendType = new ButtonType("Send", ButtonBar.ButtonData.YES); Optional optButtonType = AppServices.showAlertDialog("Link PayNym?", "Linking to this contact will allow you to send to it non-collaboratively through unique private addresses you can generate independently.\n\n" + - "It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES); - if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) { + "It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link through a notification transaction, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, previewType, ButtonType.CANCEL, sendType); + if(optButtonType.isPresent() && optButtonType.get() == sendType) { broadcastNotificationTransaction(payNym); + } else if(optButtonType.isPresent() && optButtonType.get() == previewType) { + PaymentCode paymentCode = payNym.paymentCode(); + Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false); + Wallet wallet = AppServices.get().getWallet(walletId); + EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet()))); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(wallet, List.of(payment), List.of(new byte[80]), paymentCode))); + closeProperty.set(true); } else { followingList.refresh(); } @@ -544,7 +590,7 @@ public class PayNymController { return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, false); } - private Wallet getMasterWallet() { + public Wallet getMasterWallet() { Wallet wallet = AppServices.get().getWallet(walletId); return wallet.isMasterWallet() ? wallet : wallet.getMasterWallet(); } @@ -561,6 +607,10 @@ public class PayNymController { return payNymProperty; } + public BooleanProperty closeProperty() { + return closeProperty; + } + @Subscribe public void walletHistoryChanged(WalletHistoryChangedEvent event) { List changedLabelEntries = new ArrayList<>(); diff --git a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymDialog.java b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymDialog.java index 743e4a04..70cd94de 100644 --- a/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/paynym/PayNymDialog.java @@ -48,6 +48,12 @@ public class PayNymDialog extends Dialog { dialogPane.getButtonTypes().add(doneButtonType); } + payNymController.closeProperty().addListener((observable, oldValue, newValue) -> { + if(newValue) { + close(); + } + }); + setOnCloseRequest(event -> { EventManager.get().unregister(payNymController); }); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java index d365857b..680bdab7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java @@ -18,10 +18,8 @@ import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.CurrencyRate; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.*; -import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent; -import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent; -import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent; -import com.sparrowwallet.sparrow.event.OpenWalletsEvent; +import com.sparrowwallet.sparrow.event.*; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.net.ExchangeSource; import com.sparrowwallet.sparrow.paynym.PayNym; @@ -41,7 +39,7 @@ import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.control.*; -import javafx.util.StringConverter; +import org.controlsfx.glyphfont.Glyph; import org.controlsfx.validation.ValidationResult; import org.controlsfx.validation.ValidationSupport; import org.controlsfx.validation.Validator; @@ -59,6 +57,8 @@ import static com.sparrowwallet.sparrow.AppServices.showErrorDialog; public class PaymentController extends WalletFormController implements Initializable { private static final Logger log = LoggerFactory.getLogger(PaymentController.class); + public static final long MINIMUM_P2PKH_OUTPUT_SATS = 546L; + private SendController sendController; private ValidationSupport validationSupport; @@ -115,7 +115,7 @@ public class PaymentController extends WalletFormController implements Initializ Long recipientValueSats = getRecipientValueSats(); if(recipientValueSats != null) { setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), recipientValueSats); - dustAmountProperty.set(recipientValueSats <= getRecipientDustThreshold()); + dustAmountProperty.set(recipientValueSats < getRecipientDustThreshold()); emptyAmountProperty.set(false); } else { fiatAmount.setText(""); @@ -134,7 +134,7 @@ public class PaymentController extends WalletFormController implements Initializ private static final Wallet payNymWallet = new Wallet() { @Override public String getFullDisplayName() { - return "PayNym..."; + return "PayNym or Payment code..."; } }; @@ -150,17 +150,6 @@ public class PaymentController extends WalletFormController implements Initializ @Override public void initializeView() { - openWallets.setConverter(new StringConverter<>() { - @Override - public String toString(Wallet wallet) { - return wallet == null ? "" : wallet.getFullDisplayName() + (wallet == sendController.getWalletForm().getWallet() ? " (Consolidation)" : ""); - } - - @Override - public Wallet fromString(String string) { - return null; - } - }); updateOpenWallets(); openWallets.prefWidthProperty().bind(address.widthProperty()); openWallets.valueProperty().addListener((observable, oldValue, newValue) -> { @@ -168,12 +157,7 @@ public class PaymentController extends WalletFormController implements Initializ boolean selectLinkedOnly = sendController.getPaymentTabs().getTabs().size() > 1 || !SorobanServices.canWalletMix(sendController.getWalletForm().getWallet()); PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true, selectLinkedOnly); Optional optPayNym = payNymDialog.showAndWait(); - if(optPayNym.isPresent()) { - PayNym payNym = optPayNym.get(); - payNymProperty.set(payNym); - address.setText(payNym.nymName()); - label.requestFocus(); - } + optPayNym.ifPresent(this::setPayNym); } else if(newValue != null) { WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE); Address freshAddress = freshNode.getAddress(); @@ -181,6 +165,19 @@ public class PaymentController extends WalletFormController implements Initializ label.requestFocus(); } }); + openWallets.setCellFactory(c -> new ListCell<>() { + @Override + protected void updateItem(Wallet wallet, boolean empty) { + super.updateItem(wallet, empty); + if(empty || wallet == null) { + setText(null); + setGraphic(null); + } else { + setText(wallet.getFullDisplayName() + (wallet == sendController.getWalletForm().getWallet() ? " (Consolidation)" : "")); + setGraphic(wallet == payNymWallet ? getPayNymGlyph() : null); + } + } + }); payNymProperty.addListener((observable, oldValue, payNym) -> { updateMixOnlyStatus(payNym); @@ -188,6 +185,8 @@ public class PaymentController extends WalletFormController implements Initializ }); address.textProperty().addListener((observable, oldValue, newValue) -> { + address.leftProperty().set(null); + if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) { payNymProperty.set(null); } @@ -200,6 +199,32 @@ public class PaymentController extends WalletFormController implements Initializ //ignore, not a URI } + if(sendController.getWalletForm().getWallet().hasPaymentCode()) { + try { + PaymentCode paymentCode = new PaymentCode(newValue); + Wallet recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, sendController.getWalletForm().getWallet().getScriptType()); + if(recipientBip47Wallet == null && sendController.getWalletForm().getWallet().getScriptType() != ScriptType.P2PKH) { + recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, ScriptType.P2PKH); + } + + if(recipientBip47Wallet != null) { + PayNym payNym = PayNym.fromWallet(recipientBip47Wallet); + Platform.runLater(() -> setPayNym(payNym)); + } else if(!paymentCode.equals(sendController.getWalletForm().getWallet().getPaymentCode())) { + ButtonType previewType = new ButtonType("Preview Transaction", ButtonBar.ButtonData.YES); + Optional optButton = AppServices.showAlertDialog("Send notification transaction?", "This payment code is not yet linked with a notification transaction. Send a notification transaction?", Alert.AlertType.CONFIRMATION, ButtonType.CANCEL, previewType); + if(optButton.isPresent() && optButton.get() == previewType) { + Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + paymentCode.toAbbreviatedString(), MINIMUM_P2PKH_OUTPUT_SATS, false); + Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(sendController.getWalletForm().getWallet(), List.of(payment), List.of(new byte[80]), paymentCode))); + } else { + Platform.runLater(() -> address.setText("")); + } + } + } catch(Exception e) { + //ignore, not a payment code + } + } + revalidateAmount(); maxButton.setDisable(!isMaxButtonEnabled()); sendController.updateTransaction(); @@ -253,6 +278,13 @@ public class PaymentController extends WalletFormController implements Initializ addValidation(validationSupport); } + public void setPayNym(PayNym payNym) { + payNymProperty.set(payNym); + address.setText(payNym.nymName()); + address.leftProperty().set(getPayNymGlyph()); + label.requestFocus(); + } + public void updateMixOnlyStatus() { updateMixOnlyStatus(payNymProperty.get()); } @@ -296,7 +328,7 @@ public class PaymentController extends WalletFormController implements Initializ )); validationSupport.registerValidator(amount, Validator.combine( (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", getRecipientValueSats() != null && sendController.isInsufficientInputs()), - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold()) + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() < getRecipientDustThreshold()) )); } @@ -394,7 +426,7 @@ public class PaymentController extends WalletFormController implements Initializ public void revalidateAmount() { revalidate(amount, amountListener); Long recipientValueSats = getRecipientValueSats(); - dustAmountProperty.set(recipientValueSats != null && recipientValueSats <= getRecipientDustThreshold()); + dustAmountProperty.set(recipientValueSats != null && recipientValueSats < getRecipientDustThreshold()); emptyAmountProperty.set(recipientValueSats == null); } @@ -426,7 +458,7 @@ public class PaymentController extends WalletFormController implements Initializ Address recipientAddress = getRecipientAddress(); Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats(); - if(!label.getText().isEmpty() && value != null && value > getRecipientDustThreshold()) { + if(!label.getText().isEmpty() && value != null && value >= getRecipientDustThreshold()) { Payment payment = new Payment(recipientAddress, label.getText(), value, sendAll); if(address.getUserData() != null) { payment.setType((Payment.Type)address.getUserData()); @@ -509,6 +541,8 @@ public class PaymentController extends WalletFormController implements Initializ QRScanDialog.Result result = optionalResult.get(); if(result.uri != null) { updateFromURI(result.uri); + } else if(result.payload != null) { + address.setText(result.payload); } else if(result.exception != null) { log.error("Error scanning QR", result.exception); showErrorDialog("Error scanning QR", result.exception.getMessage()); @@ -556,6 +590,14 @@ public class PaymentController extends WalletFormController implements Initializ amountUnit.setDisable(disable); scanQrButton.setDisable(disable); addPaymentButton.setDisable(disable); + maxButton.setDisable(disable); + } + + public static Glyph getPayNymGlyph() { + Glyph payNymGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ROBOT); + payNymGlyph.getStyleClass().add("paynym-icon"); + payNymGlyph.setFontSize(12); + return payNymGlyph; } @Subscribe diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index d42ea0ed..fe85ad81 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -4,10 +4,16 @@ 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.SecureString; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.bip47.PaymentCode; +import com.sparrowwallet.drongo.bip47.SecretPoint; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.protocol.TransactionOutPoint; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; @@ -19,6 +25,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.net.*; +import com.sparrowwallet.sparrow.paynym.PayNym; import com.sparrowwallet.sparrow.soroban.InitiatorDialog; import com.sparrowwallet.sparrow.paynym.PayNymAddress; import com.sparrowwallet.sparrow.soroban.SorobanServices; @@ -26,6 +33,7 @@ import com.sparrowwallet.sparrow.whirlpool.Whirlpool; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; @@ -143,6 +151,9 @@ public class SendController extends WalletFormController implements Initializabl @FXML private Button premixButton; + @FXML + private Button notificationButton; + private StackPane tabHeader; private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false); @@ -153,6 +164,8 @@ public class SendController extends WalletFormController implements Initializabl private final ObjectProperty whirlpoolProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty paymentCodeProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty walletTransactionProperty = new SimpleObjectProperty<>(null); private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false); @@ -218,8 +231,9 @@ public class SendController extends WalletFormController implements Initializabl } }; - private final ChangeListener premixButtonOnlineListener = (observable, oldValue, newValue) -> { + private final ChangeListener broadcastButtonsOnlineListener = (observable, oldValue, newValue) -> { premixButton.setDisable(!newValue); + notificationButton.setDisable(walletTransactionProperty.get() == null || isInsufficientFeeRate() || !newValue); }; private ValidationSupport validationSupport; @@ -397,6 +411,7 @@ public class SendController extends WalletFormController implements Initializabl transactionDiagram.update(walletTransaction); updatePrivacyAnalysis(walletTransaction); createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymMixOnlyPayment(walletTransaction.getPayments())); + notificationButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || !AppServices.isConnected()); }); transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> { @@ -430,9 +445,11 @@ public class SendController extends WalletFormController implements Initializabl createButton.managedProperty().bind(createButton.visibleProperty()); premixButton.managedProperty().bind(premixButton.visibleProperty()); - createButton.visibleProperty().bind(premixButton.visibleProperty().not()); + notificationButton.managedProperty().bind(notificationButton.visibleProperty()); + createButton.visibleProperty().bind(Bindings.and(premixButton.visibleProperty().not(), notificationButton.visibleProperty().not())); premixButton.setVisible(false); - AppServices.onlineProperty().addListener(new WeakChangeListener<>(premixButtonOnlineListener)); + notificationButton.setVisible(false); + AppServices.onlineProperty().addListener(new WeakChangeListener<>(broadcastButtonsOnlineListener)); } private void initializeTabHeader(int count) { @@ -582,6 +599,7 @@ public class SendController extends WalletFormController implements Initializabl if(currentWalletTransactionService.isRunning()) { transactionDiagram.update("Selecting UTXOs..."); createButton.setDisable(true); + notificationButton.setDisable(true); } }); final Timeline timeline = new Timeline(delay); @@ -1047,13 +1065,17 @@ public class SendController extends WalletFormController implements Initializabl validationSupport.setErrorDecorationEnabled(false); - setInputFieldsDisabled(false); + setInputFieldsDisabled(false, false); efficiencyToggle.setDisable(false); privacyToggle.setDisable(false); premixButton.setVisible(false); + notificationButton.setVisible(false); createButton.setDefaultButton(true); + + whirlpoolProperty.set(null); + paymentCodeProperty.set(null); } public UtxoSelector getUtxoSelector() { @@ -1181,18 +1203,182 @@ public class SendController extends WalletFormController implements Initializabl tx0BroadcastService.start(); } - private void setInputFieldsDisabled(boolean disable) { + public void broadcastNotification(ActionEvent event) { + Wallet wallet = getWalletForm().getWallet(); + Storage storage = AppServices.get().getOpenWallets().get(wallet); + if(wallet.isEncrypted()) { + WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get()); + decryptWalletService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done")); + Wallet decryptedWallet = decryptWalletService.getValue(); + broadcastNotification(decryptedWallet); + decryptedWallet.clearPrivate(); + }); + decryptWalletService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed")); + AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage()); + }); + EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet...")); + decryptWalletService.start(); + } + } else { + broadcastNotification(wallet); + } + } + + public void broadcastNotification(Wallet decryptedWallet) { + try { + PaymentCode paymentCode = decryptedWallet.getPaymentCode(); + PaymentCode externalPaymentCode = paymentCodeProperty.get(); + WalletTransaction walletTransaction = walletTransactionProperty.get(); + WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue(); + 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 = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize()); + byte[] blindedPaymentCode = PaymentCode.blind(paymentCode.getPayload(), blindingMask); + + List utxoSelectors = List.of(new PresetUtxoSelector(walletTransaction.getSelectedUtxos().keySet(), true)); + Long userFee = userFeeSet.get() ? getFeeValueSats() : null; + double feeRate = getUserFeeRate(); + Integer currentBlockHeight = AppServices.getCurrentBlockHeight(); + boolean groupByAddress = Config.get().isGroupByAddress(); + boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs(); + boolean includeSpentMempoolOutputs = includeSpentMempoolOutputsProperty.get(); + + WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(utxoSelectors, getUtxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode), excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs); + PSBT psbt = finalWalletTx.createPSBT(); + decryptedWallet.sign(psbt); + decryptedWallet.finalise(psbt); + Transaction transaction = psbt.extractTransaction(); + + ServiceProgressDialog.ProxyWorker proxyWorker = new ServiceProgressDialog.ProxyWorker(); + ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(transaction); + broadcastTransactionService.setOnSucceeded(successEvent -> { + ElectrumServer.TransactionMempoolService transactionMempoolService = new ElectrumServer.TransactionMempoolService(walletTransaction.getWallet(), transaction.getTxId(), new HashSet<>(walletTransaction.getSelectedUtxos().values())); + transactionMempoolService.setDelay(Duration.seconds(2)); + transactionMempoolService.setPeriod(Duration.seconds(5)); + transactionMempoolService.setRestartOnFailure(false); + transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> { + Set scriptHashes = transactionMempoolService.getValue(); + if(!scriptHashes.isEmpty()) { + transactionMempoolService.cancel(); + clear(null); + if(Config.get().isUsePayNym()) { + proxyWorker.setMessage("Finding PayNym..."); + AppServices.getPayNymService().getPayNym(externalPaymentCode.toString()).subscribe(payNym -> { + proxyWorker.end(); + addChildWallets(walletTransaction.getWallet(), externalPaymentCode, transaction, payNym); + }, error -> { + proxyWorker.end(); + addChildWallets(walletTransaction.getWallet(), externalPaymentCode, transaction, null); + }); + } else { + proxyWorker.end(); + addChildWallets(walletTransaction.getWallet(), externalPaymentCode, transaction, null); + } + } + + if(transactionMempoolService.getIterationCount() > 5 && transactionMempoolService.isRunning()) { + transactionMempoolService.cancel(); + proxyWorker.end(); + log.error("Timeout searching for broadcasted notification transaction"); + AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again."); + } + }); + transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> { + transactionMempoolService.cancel(); + proxyWorker.end(); + log.error("Error searching for broadcasted notification transaction", mempoolWorkerStateEvent.getSource().getException()); + AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again."); + }); + proxyWorker.setMessage("Receiving notification transaction..."); + transactionMempoolService.start(); + }); + broadcastTransactionService.setOnFailed(failedEvent -> { + proxyWorker.end(); + log.error("Error broadcasting notification transaction", failedEvent.getSource().getException()); + AppServices.showErrorDialog("Error broadcasting notification transaction", failedEvent.getSource().getException().getMessage()); + }); + ServiceProgressDialog progressDialog = new ServiceProgressDialog("Broadcast", "Broadcast Notification Transaction", "/image/paynym.png", proxyWorker); + AppServices.moveToActiveWindowScreen(progressDialog); + proxyWorker.setMessage("Broadcasting notification transaction..."); + proxyWorker.start(); + broadcastTransactionService.start(); + } catch(Exception e) { + log.error("Error creating notification transaction", e); + AppServices.showErrorDialog("Error creating notification transaction", e.getMessage()); + } + } + + private void addChildWallets(Wallet wallet, PaymentCode externalPaymentCode, Transaction transaction, PayNym payNym) { + List addedWallets = addChildWallets(externalPaymentCode, payNym); + Wallet masterWallet = getWalletForm().getMasterWallet(); + Storage storage = AppServices.get().getOpenWallets().get(masterWallet); + EventManager.get().post(new ChildWalletsAddedEvent(storage, masterWallet, addedWallets)); + + BlockTransaction blockTransaction = wallet.getWalletTransaction(transaction.getTxId()); + if(blockTransaction != null && blockTransaction.getLabel() == null) { + blockTransaction.setLabel("Link " + (payNym == null ? externalPaymentCode.toAbbreviatedString() : payNym.nymName())); + TransactionEntry transactionEntry = new TransactionEntry(wallet, blockTransaction, Collections.emptyMap(), Collections.emptyMap()); + EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, List.of(transactionEntry))); + } + + if(paymentTabs.getTabs().size() > 0 && !addedWallets.isEmpty()) { + Wallet addedWallet = addedWallets.stream().filter(w -> w.getScriptType() == ScriptType.P2WPKH).findFirst().orElse(addedWallets.iterator().next()); + PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData(); + controller.setPayNym(payNym == null ? PayNym.fromWallet(addedWallet) : payNym); + } + + Glyph successGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE); + successGlyph.getStyleClass().add("success"); + successGlyph.setFontSize(50); + + AppServices.showAlertDialog("Notification Successful", "The notification transaction was successfully sent for payment code " + + externalPaymentCode.toAbbreviatedString() + (payNym == null ? "" : " (" + payNym.nymName() + ")") + + ".\n\nYou can send to it by entering the payment code, or selecting `PayNym or Payment code` in the Pay to dropdown.", Alert.AlertType.INFORMATION, successGlyph, ButtonType.OK); + } + + public List addChildWallets(PaymentCode externalPaymentCode, PayNym payNym) { + List addedWallets = new ArrayList<>(); + Wallet masterWallet = getWalletForm().getMasterWallet(); + Storage storage = AppServices.get().getOpenWallets().get(masterWallet); + List scriptTypes = PayNym.getSegwitScriptTypes(); + for(ScriptType childScriptType : scriptTypes) { + Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType); + addedWallet.setLabel((payNym == null ? externalPaymentCode.toAbbreviatedString() : payNym.nymName()) + " " + childScriptType.getName()); + if(!storage.isPersisted(addedWallet)) { + try { + storage.saveWallet(addedWallet); + } catch(Exception e) { + log.error("Error saving wallet", e); + AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage()); + } + } + addedWallets.add(addedWallet); + } + + return addedWallets; + } + + private void setInputFieldsDisabled(boolean disablePayments, boolean disableFeeSelection) { for(int i = 0; i < paymentTabs.getTabs().size(); i++) { Tab tab = paymentTabs.getTabs().get(i); - tab.setClosable(!disable); + tab.setClosable(!disablePayments); PaymentController controller = (PaymentController)tab.getUserData(); - controller.setInputFieldsDisabled(disable); - - feeRange.setDisable(disable); - targetBlocks.setDisable(disable); - fee.setDisable(disable); - feeAmountUnit.setDisable(disable); + controller.setInputFieldsDisabled(disablePayments); } + + feeRange.setDisable(disableFeeSelection); + targetBlocks.setDisable(disableFeeSelection); + fee.setDisable(disableFeeSelection); + feeAmountUnit.setDisable(disableFeeSelection); + + transactionDiagram.requestFocus(); } @Subscribe @@ -1262,11 +1448,15 @@ public class SendController extends WalletFormController implements Initializabl @Subscribe public void spendUtxos(SpendUtxoEvent event) { - if(!event.getUtxos().isEmpty() && event.getWallet().equals(getWalletForm().getWallet())) { + if((event.getUtxos() == null || !event.getUtxos().isEmpty()) && event.getWallet().equals(getWalletForm().getWallet())) { + if(whirlpoolProperty.get() != null || paymentCodeProperty.get() != null) { + clear(null); + } + if(event.getPayments() != null) { clear(null); setPayments(event.getPayments()); - } else if(paymentTabs.getTabs().size() == 1) { + } else if(paymentTabs.getTabs().size() == 1 && event.getUtxos() != null) { Payment payment = new Payment(null, null, event.getUtxos().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(), true); setPayments(List.of(payment)); } @@ -1282,16 +1472,25 @@ public class SendController extends WalletFormController implements Initializabl includeSpentMempoolOutputsProperty.set(event.isIncludeSpentMempoolOutputs()); - List utxos = event.getUtxos(); - utxoSelectorProperty.set(new PresetUtxoSelector(utxos)); + if(event.getUtxos() != null) { + List utxos = event.getUtxos(); + utxoSelectorProperty.set(new PresetUtxoSelector(utxos)); + } + utxoFilterProperty.set(null); whirlpoolProperty.set(event.getPool()); + paymentCodeProperty.set(event.getPaymentCode()); updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax)); boolean isWhirlpoolPremix = (event.getPool() != null); - setInputFieldsDisabled(isWhirlpoolPremix); premixButton.setVisible(isWhirlpoolPremix); premixButton.setDefaultButton(isWhirlpoolPremix); + + boolean isNotificationTransaction = (event.getPaymentCode() != null); + notificationButton.setVisible(isNotificationTransaction); + notificationButton.setDefaultButton(isNotificationTransaction); + + setInputFieldsDisabled(isWhirlpoolPremix || isNotificationTransaction, isWhirlpoolPremix); } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/paynym/paynym.fxml b/src/main/resources/com/sparrowwallet/sparrow/paynym/paynym.fxml index 6ff018f8..7cf8f99f 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/paynym/paynym.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/paynym/paynym.fxml @@ -64,7 +64,7 @@