Browse Source

support linking and sending to payment codes without paynym.is

terminal
Craig Raw 3 years ago
parent
commit
a765e07c10
  1. 5
      src/main/java/com/sparrowwallet/sparrow/AppController.java
  2. 7
      src/main/java/com/sparrowwallet/sparrow/AppServices.java
  3. 27
      src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
  4. 4
      src/main/java/com/sparrowwallet/sparrow/control/PayNymAvatar.java
  5. 37
      src/main/java/com/sparrowwallet/sparrow/control/PayNymCell.java
  6. 114
      src/main/java/com/sparrowwallet/sparrow/control/ServiceProgressDialog.java
  7. 20
      src/main/java/com/sparrowwallet/sparrow/control/WalletLabelDialog.java
  8. 25
      src/main/java/com/sparrowwallet/sparrow/event/SpendUtxoEvent.java
  9. 20
      src/main/java/com/sparrowwallet/sparrow/paynym/PayNym.java
  10. 74
      src/main/java/com/sparrowwallet/sparrow/paynym/PayNymController.java
  11. 6
      src/main/java/com/sparrowwallet/sparrow/paynym/PayNymDialog.java
  12. 96
      src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
  13. 229
      src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java
  14. 2
      src/main/resources/com/sparrowwallet/sparrow/paynym/paynym.fxml
  15. 3
      src/main/resources/com/sparrowwallet/sparrow/wallet/payment.css
  16. 2
      src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml
  17. 5
      src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml

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

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

@ -710,11 +710,18 @@ public class AppServices {
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
return showAlertDialog(title, content, alertType, null, buttons);
}
public static Optional<ButtonType> 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);

27
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<Entry, Entry> {
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<byte[]> opReturns = externalOutputs.stream().map(txOutput -> {
List<ScriptChunk> 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<Entry, Entry> {
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) {

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

37
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<PayNym> {
private final PayNymController payNymController;
@ -83,6 +88,12 @@ public class PayNymCell extends ListCell<PayNym> {
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<PayNym> {
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<String> 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);
}
}
}

114
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<Boolean> {
private final ObjectProperty<State> 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<State> stateProperty() {
return state;
}
@Override
public Boolean getValue() {
return Boolean.TRUE;
}
@Override
public ReadOnlyObjectProperty<Boolean> valueProperty() {
return null;
}
@Override
public Throwable getException() {
return null;
}
@Override
public ReadOnlyObjectProperty<Throwable> 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;
}
}
}

20
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<String> {
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<String> {
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);
}

25
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<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, false);
this(wallet, utxos, null, null, null, false);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeSpentMempoolOutputs) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> 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<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> 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<Payment> payments, List<byte[]> 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;
}
}

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

74
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<Sha256Hash, PayNym> 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<ButtonType> 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<PayNym> followingPayNyms = new ArrayList<>();
if(walletPayNym != null) {
followingPayNyms.addAll(walletPayNym.following());
}
Map<PaymentCode, PayNym> followingPayNymMap = followingPayNyms.stream().collect(Collectors.toMap(PayNym::paymentCode, Function.identity()));
followingPayNyms.addAll(getExistingWalletPayNyms(followingPayNymMap));
followingList.setItems(FXCollections.observableList(followingPayNyms));
}
private List<PayNym> getExistingWalletPayNyms(Map<PaymentCode, PayNym> followingPayNymMap) {
Map<PaymentCode, PayNym> existingPayNyms = new LinkedHashMap<>();
List<Wallet> 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<PayNym> following) {
Map<BlockTransaction, PayNym> unlinkedPayNyms = new HashMap<>();
Map<BlockTransaction, WalletNode> 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<ButtonType> 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<Entry> changedLabelEntries = new ArrayList<>();

6
src/main/java/com/sparrowwallet/sparrow/paynym/PayNymDialog.java

@ -48,6 +48,12 @@ public class PayNymDialog extends Dialog<PayNym> {
dialogPane.getButtonTypes().add(doneButtonType);
}
payNymController.closeProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
close();
}
});
setOnCloseRequest(event -> {
EventManager.get().unregister(payNymController);
});

96
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<PayNym> 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<ButtonType> 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

229
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<Pool> whirlpoolProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<PaymentCode> paymentCodeProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<WalletTransaction> 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<Boolean> premixButtonOnlineListener = (observable, oldValue, newValue) -> {
private final ChangeListener<Boolean> 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<SecureString> 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<UtxoSelector> 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<String> 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<Wallet> 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<Wallet> addChildWallets(PaymentCode externalPaymentCode, PayNym payNym) {
List<Wallet> addedWallets = new ArrayList<>();
Wallet masterWallet = getWalletForm().getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
List<ScriptType> 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());
if(event.getUtxos() != null) {
List<BlockTransactionHashIndex> 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);
}
}

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

@ -64,7 +64,7 @@
<Label text="Find Contact:" styleClass="field-label" />
<HBox spacing="10">
<CopyableTextField fx:id="searchPayNyms" promptText="PayNym or Payment code" styleClass="field-control"/>
<Button onAction="#scanQR">
<Button fx:id="searchPayNymsScan" onAction="#scanQR">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>

3
src/main/resources/com/sparrowwallet/sparrow/wallet/payment.css

@ -0,0 +1,3 @@
#address .paynym-icon {
-fx-padding: 0 0 0 5;
}

2
src/main/resources/com/sparrowwallet/sparrow/wallet/payment.fxml

@ -35,7 +35,7 @@
<ComboBox fx:id="openWallets" />
<ComboBoxTextField fx:id="address" styleClass="address-text-field" comboProperty="$openWallets">
<tooltip>
<Tooltip text="Address or bitcoin: URI"/>
<Tooltip text="Address, payment code or bitcoin: URI"/>
</tooltip>
</ComboBoxTextField>
</StackPane>

5
src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml

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

Loading…
Cancel
Save