From 013ed89e98f38e56bf8293632da21833b5c5e5d6 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Fri, 3 Jul 2020 11:01:09 +0200 Subject: [PATCH] send controller utxo selection --- drongo | 2 +- .../sparrow/control/TextFieldValidator.java | 6 +- .../sparrow/control/TransactionDiagram.java | 95 ++++++++++++++ .../wallet/InvalidTransactionException.java | 20 +++ .../sparrow/wallet/SendController.java | 124 ++++++++++++++++-- .../sparrow/wallet/WalletUtxosEntry.java | 24 +--- .../sparrowwallet/sparrow/wallet/send.fxml | 13 ++ 7 files changed, 247 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/InvalidTransactionException.java diff --git a/drongo b/drongo index c4dd1cb9..3ee7cd11 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit c4dd1cb9dd40a7a16829a00f45acbd55f63d9895 +Subproject commit 3ee7cd11eb31da06d79132f0023e6da7e534906d diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TextFieldValidator.java b/src/main/java/com/sparrowwallet/sparrow/control/TextFieldValidator.java index 3abde444..b4a42681 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TextFieldValidator.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TextFieldValidator.java @@ -10,7 +10,7 @@ import javafx.scene.control.TextFormatter.Change; public class TextFieldValidator { private static final String CURRENCY_SYMBOL = DecimalFormatSymbols.getInstance().getCurrencySymbol(); - private static final char DECIMAL_SEPARATOR = DecimalFormatSymbols.getInstance().getDecimalSeparator(); + private static final String DECIMAL_SEPARATOR = "."; private final Pattern INPUT_PATTERN; @@ -54,11 +54,11 @@ public class TextFieldValidator { } private static Pattern maxFractionPattern(int countOf) { - return Pattern.compile("\\d*(\\\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?"); + return Pattern.compile("\\d*(\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?"); } private static Pattern maxCurrencyFractionPattern(int countOf) { - return Pattern.compile("^\\\\" + CURRENCY_SYMBOL + "?\\s?\\d*(\\\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?\\s?\\\\" + CURRENCY_SYMBOL + "?"); + return Pattern.compile("^\\\\" + CURRENCY_SYMBOL + "?\\s?\\d*(\\" + DECIMAL_SEPARATOR + "\\d{0," + countOf + "})?\\s?\\\\" + CURRENCY_SYMBOL + "?"); } private static Pattern maxIntegerPattern(int countOf) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java new file mode 100644 index 00000000..eb7aa0e2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/TransactionDiagram.java @@ -0,0 +1,95 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.*; + +import java.util.Collection; + +public class TransactionDiagram extends GridPane { + public TransactionDiagram() { + int columns = 5; + double percentWidth = 100.0 / columns; + + for(int i = 0; i < columns; i++) { + ColumnConstraints columnConstraints = new ColumnConstraints(); + columnConstraints.setPercentWidth(percentWidth); + getColumnConstraints().add(columnConstraints); + } + } + + public void update(Wallet wallet, Collection inputs, Address toAddress, WalletNode changeNode, long fee) { + Pane inputsPane = getInputsLabels(inputs); + GridPane.setConstraints(inputsPane, 0, 0); + + Pane txPane = getTransactionPane(); + GridPane.setConstraints(inputsPane, 2, 0); + + Pane outputsPane = getOutputsLabels(wallet, toAddress, changeNode, fee); + GridPane.setConstraints(inputsPane, 4, 0); + + getChildren().clear(); + getChildren().addAll(inputsPane, txPane, outputsPane); + } + + private Pane getInputsLabels(Collection inputs) { + VBox inputsBox = new VBox(); + inputsBox.setAlignment(Pos.CENTER_RIGHT); + inputsBox.getChildren().add(createSpacer()); + for(BlockTransactionHashIndex input : inputs) { + String desc = input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "...:" + input.getIndex(); + Label label = new Label(desc); + inputsBox.getChildren().add(label); + inputsBox.getChildren().add(createSpacer()); + } + + return inputsBox; + } + + private Pane getOutputsLabels(Wallet wallet, Address toAddress, WalletNode changeNode, long fee) { + VBox outputsBox = new VBox(); + outputsBox.setAlignment(Pos.CENTER_LEFT); + outputsBox.getChildren().add(createSpacer()); + + String addressDesc = toAddress.toString(); + Label addressLabel = new Label(addressDesc); + outputsBox.getChildren().add(addressLabel); + outputsBox.getChildren().add(createSpacer()); + + String changeDesc = wallet.getAddress(changeNode).toString(); + Label changeLabel = new Label(changeDesc); + outputsBox.getChildren().add(changeLabel); + outputsBox.getChildren().add(createSpacer()); + + String feeDesc = "Fee"; + Label feeLabel = new Label(feeDesc); + outputsBox.getChildren().add(feeLabel); + outputsBox.getChildren().add(createSpacer()); + + return outputsBox; + } + + private Pane getTransactionPane() { + VBox txPane = new VBox(); + txPane.setAlignment(Pos.CENTER); + txPane.getChildren().add(createSpacer()); + + String txDesc = "Transaction"; + Label txLabel = new Label(txDesc); + txPane.getChildren().add(txLabel); + txPane.getChildren().add(createSpacer()); + + return txPane; + } + + private Node createSpacer() { + final Region spacer = new Region(); + VBox.setVgrow(spacer, Priority.ALWAYS); + return spacer; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/InvalidTransactionException.java b/src/main/java/com/sparrowwallet/sparrow/wallet/InvalidTransactionException.java new file mode 100644 index 00000000..60ac161d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/InvalidTransactionException.java @@ -0,0 +1,20 @@ +package com.sparrowwallet.sparrow.wallet; + +public class InvalidTransactionException extends Exception { + public InvalidTransactionException() { + super(); + } + + public InvalidTransactionException(String msg) { + super(msg); + } + + /** + * Thrown when there are not enough selected inputs to pay the total output value + */ + public static class InsufficientInputsException extends InvalidTransactionException { + public InsufficientInputsException(String msg) { + super(msg); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index c7209922..55240a8e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -3,16 +3,19 @@ package com.sparrowwallet.sparrow.wallet; import com.google.common.eventbus.Subscribe; import com.sparrowwallet.drongo.address.Address; import com.sparrowwallet.drongo.address.InvalidAddressException; -import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.protocol.Transaction; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppController; import com.sparrowwallet.sparrow.BitcoinUnit; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.control.CopyableLabel; import com.sparrowwallet.sparrow.control.CopyableTextField; import com.sparrowwallet.sparrow.control.FeeRatesChart; +import com.sparrowwallet.sparrow.control.TextFieldValidator; import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; @@ -24,9 +27,7 @@ import org.controlsfx.validation.Validator; import org.controlsfx.validation.decoration.StyleClassValidationDecoration; import java.net.URL; -import java.util.List; -import java.util.Map; -import java.util.ResourceBundle; +import java.util.*; public class SendController extends WalletFormController implements Initializable { public static final List TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50); @@ -58,6 +59,19 @@ public class SendController extends WalletFormController implements Initializabl @FXML private FeeRatesChart feeRatesChart; + @FXML + private Button clear; + + @FXML + private Button select; + + @FXML + private Button create; + + private ObservableList inputs; + + private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false); + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -65,13 +79,22 @@ public class SendController extends WalletFormController implements Initializabl @Override public void initializeView() { - ValidationSupport validationSupport = new ValidationSupport(); - validationSupport.registerValidator(address, Validator.combine( - (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !isValidAddress()) - )); - validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + addValidation(); + address.textProperty().addListener((observable, oldValue, newValue) -> { + updateTransaction(); + }); + + amount.setTextFormatter(new TextFieldValidator(TextFieldValidator.ValidationModus.MAX_FRACTION_DIGITS, 15).getFormatter()); amountUnit.getSelectionModel().select(0); + amount.textProperty().addListener((observable, oldValue, newValue) -> { + updateTransaction(); + }); + insufficientInputsProperty.addListener((observable, oldValue, newValue) -> { + String amt = amount.getText(); + amount.setText(amt + " "); + amount.setText(amt); + }); targetBlocks.setMin(0); targetBlocks.setMax(TARGET_BLOCKS_RANGE.size() - 1); @@ -116,6 +139,65 @@ public class SendController extends WalletFormController implements Initializabl } setTargetBlocks(5); + + fee.textProperty().addListener((observable, oldValue, newValue) -> { + updateTransaction(); + }); + + select.managedProperty().bind(select.visibleProperty()); + create.managedProperty().bind(create.visibleProperty()); + if(inputs == null || inputs.isEmpty()) { + create.setVisible(false); + } else { + select.setVisible(false); + } + } + + private void addValidation() { + ValidationSupport validationSupport = new ValidationSupport(); + validationSupport.registerValidator(address, Validator.combine( + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !isValidAddress()) + )); + validationSupport.registerValidator(amount, Validator.combine( + (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", insufficientInputsProperty.get()) + )); + + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + } + + private void updateTransaction() { + try { + Address address = getAddress(); + Long amount = getAmount(); + if(amount != null) { + Collection selectedInputs = selectInputs(amount); + + Transaction transaction = new Transaction(); + } + } catch (InvalidAddressException e) { + //ignore + } catch (InvalidTransactionException.InsufficientInputsException e) { + insufficientInputsProperty.set(true); + } + } + + private Collection selectInputs(Long targetValue) throws InvalidTransactionException.InsufficientInputsException { + Set utxos = getWalletForm().getWallet().getWalletUtxos().keySet(); + + for(UtxoSelector utxoSelector : getUtxoSelectors()) { + Collection selectedInputs = utxoSelector.select(targetValue, utxos); + long total = selectedInputs.stream().mapToLong(BlockTransactionHashIndex::getValue).sum(); + if(total > targetValue) { + return selectedInputs; + } + } + + throw new InvalidTransactionException.InsufficientInputsException("Not enough inputs for output value " + targetValue); + } + + private List getUtxoSelectors() { + UtxoSelector priorityUtxoSelector = new PriorityUtxoSelector(AppController.getCurrentBlockHeight()); + return List.of(priorityUtxoSelector); } private boolean isValidAddress() { @@ -132,6 +214,26 @@ public class SendController extends WalletFormController implements Initializabl return Address.fromString(address.getText()); } + private Long getAmount() { + BitcoinUnit bitcoinUnit = amountUnit.getSelectionModel().getSelectedItem(); + if(amount.getText() != null && !amount.getText().isEmpty()) { + Double fieldValue = Double.parseDouble(amount.getText()); + return bitcoinUnit.getSatsValue(fieldValue); + } + + return null; + } + + private Long getFee() { + BitcoinUnit bitcoinUnit = amountUnit.getSelectionModel().getSelectedItem(); + if(amount.getText() != null && !amount.getText().isEmpty()) { + Double fieldValue = Double.parseDouble(amount.getText()); + return bitcoinUnit.getSatsValue(fieldValue); + } + + return null; + } + private Integer getTargetBlocks() { int index = (int)targetBlocks.getValue(); return TARGET_BLOCKS_RANGE.get(index); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java index 9cc32c40..45763856 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletUtxosEntry.java @@ -1,9 +1,6 @@ package com.sparrowwallet.sparrow.wallet; -import com.sparrowwallet.drongo.KeyPurpose; -import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex; import com.sparrowwallet.drongo.wallet.Wallet; -import com.sparrowwallet.drongo.wallet.WalletNode; import java.util.*; import java.util.stream.Collectors; @@ -12,7 +9,7 @@ public class WalletUtxosEntry extends Entry { private final Wallet wallet; public WalletUtxosEntry(Wallet wallet) { - super(wallet.getName(), getWalletUtxos(wallet).entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList())); + super(wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList())); this.wallet = wallet; calculateDuplicates(); } @@ -45,7 +42,7 @@ public class WalletUtxosEntry extends Entry { } public void updateUtxos() { - List current = getWalletUtxos(wallet).entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()); + List current = wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(wallet, entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()); List previous = new ArrayList<>(getChildren()); List entriesAdded = new ArrayList<>(current); @@ -58,21 +55,4 @@ public class WalletUtxosEntry extends Entry { calculateDuplicates(); } - - private static Map getWalletUtxos(Wallet wallet) { - Map walletUtxos = new TreeMap<>(); - - getWalletUtxos(wallet, walletUtxos, wallet.getNode(KeyPurpose.RECEIVE)); - getWalletUtxos(wallet, walletUtxos, wallet.getNode(KeyPurpose.CHANGE)); - - return walletUtxos; - } - - private static void getWalletUtxos(Wallet wallet, Map walletUtxos, WalletNode purposeNode) { - for(WalletNode addressNode : purposeNode.getChildren()) { - for(BlockTransactionHashIndex utxo : addressNode.getUnspentTransactionOutputs()) { - walletUtxos.put(utxo, addressNode); - } - } - } } diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml index 0595ccb4..884da75d 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml @@ -89,4 +89,17 @@ + + + + + + +