send to multiple recipients

This commit is contained in:
Craig Raw 2020-10-20 10:56:08 +02:00
parent ee9247c066
commit ac438ec023
14 changed files with 660 additions and 247 deletions

2
drongo

@ -1 +1 @@
Subproject commit 8b07336d71f32094acb8eb8c162ebd8621ffc4aa
Subproject commit c4f5218f29ef58e9ce265373206a093157610fdb

View file

@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
@ -24,6 +25,7 @@ import org.controlsfx.glyphfont.FontAwesome;
import org.controlsfx.glyphfont.Glyph;
import java.util.*;
import java.util.stream.Collectors;
public class TransactionDiagram extends GridPane {
private static final int MAX_UTXOS = 8;
@ -76,7 +78,7 @@ public class TransactionDiagram extends GridPane {
Map<BlockTransactionHashIndex, WalletNode> utxos = new LinkedHashMap<>();
List<BlockTransactionHashIndex> additional = new ArrayList<>();
for(BlockTransactionHashIndex reference : selectedUtxos.keySet()) {
if (utxos.size() < MAX_UTXOS) {
if(utxos.size() < MAX_UTXOS - 1) {
utxos.put(reference, selectedUtxos.get(reference));
} else {
additional.add(reference);
@ -169,7 +171,8 @@ public class TransactionDiagram extends GridPane {
Tooltip tooltip = new Tooltip();
if(walletNode != null) {
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + walletNode.getDerivationPath() + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
KeyDerivation fullDerivation = walletTx.getWallet().getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
tooltip.setText("Spending " + getSatsValue(input.getValue()) + " sats from " + fullDerivation.getDerivationPath() + "\n" + input.getHashAsString() + ":" + input.getIndex() + "\n" + walletTx.getWallet().getAddress(walletNode));
tooltip.getStyleClass().add("input-label");
if(input.getLabel() == null || input.getLabel().isEmpty()) {
@ -252,7 +255,7 @@ public class TransactionDiagram extends GridPane {
List<Payment> displayedPayments = new ArrayList<>();
List<Payment> additional = new ArrayList<>();
for(Payment payment : payments) {
if(displayedPayments.size() < MAX_PAYMENTS) {
if(displayedPayments.size() < MAX_PAYMENTS - 1) {
displayedPayments.add(payment);
} else {
additional.add(payment);
@ -316,8 +319,9 @@ public class TransactionDiagram extends GridPane {
boolean isConsolidation = walletTx.isConsolidationSend(payment);
String recipientDesc = payment instanceof AdditionalPayment ? payment.getLabel() : payment.getAddress().toString().substring(0, 8) + "...";
Label recipientLabel = new Label(recipientDesc, isConsolidation ? getConsolidationGlyph() : getPaymentGlyph());
recipientLabel.getStyleClass().addAll("output-label", "recipient-label");
Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to\n" + payment.getAddress().toString());
recipientLabel.getStyleClass().add("output-label");
recipientLabel.getStyleClass().add(payment instanceof AdditionalPayment ? "additional-label" : "recipient-label");
Tooltip recipientTooltip = new Tooltip((isConsolidation ? "Consolidate " : "Pay ") + getSatsValue(payment.getAmount()) + " sats to " + (payment instanceof AdditionalPayment ? "\n" + payment : payment.getLabel() + "\n" + payment.getAddress().toString()));
recipientTooltip.getStyleClass().add("recipient-label");
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
recipientLabel.setTooltip(recipientTooltip);
@ -329,7 +333,8 @@ public class TransactionDiagram extends GridPane {
String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "...";
Label changeLabel = new Label(changeDesc, getChangeGlyph());
changeLabel.getStyleClass().addAll("output-label", "change-label");
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath() + "\n" + walletTx.getChangeAddress().toString());
KeyDerivation fullDerivation = walletTx.getWallet().getKeystores().get(0).getKeyDerivation().extend(walletTx.getChangeNode().getDerivation());
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + fullDerivation.getDerivationPath() + "\n" + walletTx.getChangeAddress().toString());
changeTooltip.getStyleClass().add("change-label");
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
changeLabel.setTooltip(changeTooltip);
@ -360,6 +365,8 @@ public class TransactionDiagram extends GridPane {
String txDesc = "Transaction";
Label txLabel = new Label(txDesc);
Tooltip tooltip = new Tooltip(walletTx.getTransaction().getLength() + " bytes\n" + walletTx.getTransaction().getVirtualSize() + " vBytes");
tooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
tooltip.getStyleClass().add("transaction-tooltip");
txLabel.setTooltip(tooltip);
txPane.getChildren().add(txLabel);
txPane.getChildren().add(createSpacer());
@ -441,8 +448,15 @@ public class TransactionDiagram extends GridPane {
}
private static class AdditionalPayment extends Payment {
private final List<Payment> additionalPayments;
public AdditionalPayment(List<Payment> additionalPayments) {
super(null, additionalPayments.size() + " more...", additionalPayments.stream().map(Payment::getAmount).mapToLong(v -> v).sum(), false);
this.additionalPayments = additionalPayments;
}
public String toString() {
return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n"));
}
}
}

View file

@ -35,6 +35,7 @@ public class FontAwesome5 extends GlyphFont {
LOCK('\uf023'),
LOCK_OPEN('\uf3c1'),
PEN_FANCY('\uf5ac'),
PLUS('\uf067'),
QRCODE('\uf029'),
QUESTION_CIRCLE('\uf059'),
REPLY_ALL('\uf122'),

View file

@ -0,0 +1,334 @@
package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.MaxUtxoSelector;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.UtxoSelector;
import com.sparrowwallet.sparrow.AppController;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.CoinTextFormatter;
import com.sparrowwallet.sparrow.control.CopyableTextField;
import com.sparrowwallet.sparrow.control.FiatLabel;
import com.sparrowwallet.sparrow.control.QRScanDialog;
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.*;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import java.net.URL;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
public class PaymentController extends WalletFormController implements Initializable {
private SendController sendController;
private ValidationSupport validationSupport;
@FXML
private CopyableTextField address;
@FXML
private TextField label;
@FXML
private TextField amount;
@FXML
private ComboBox<BitcoinUnit> amountUnit;
@FXML
private FiatLabel fiatAmount;
@FXML
private Button maxButton;
@FXML
private Button addPaymentButton;
private final ChangeListener<String> amountListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if(sendController.getUtxoSelector() instanceof MaxUtxoSelector) {
sendController.utxoSelectorProperty().setValue(null);
}
Long recipientValueSats = getRecipientValueSats();
if(recipientValueSats != null) {
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), recipientValueSats);
} else {
fiatAmount.setText("");
}
sendController.updateTransaction();
}
};
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
}
public void setSendController(SendController sendController) {
this.sendController = sendController;
this.validationSupport = sendController.getValidationSupport();
}
@Override
public void initializeView() {
address.textProperty().addListener((observable, oldValue, newValue) -> {
revalidate(amount, amountListener);
maxButton.setDisable(!isValidRecipientAddress());
sendController.updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
});
label.textProperty().addListener((observable, oldValue, newValue) -> {
sendController.getCreateButton().setDisable(sendController.getWalletTransaction() == null || newValue == null || newValue.isEmpty() || sendController.isInsufficientFeeRate());
sendController.updateTransaction();
});
amount.setTextFormatter(new CoinTextFormatter());
amount.textProperty().addListener(amountListener);
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(sendController.getBitcoinUnit(Config.get().getBitcoinUnit())) ? 0 : 1);
amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
Long value = getRecipientValueSats(oldValue);
if(value != null) {
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
df.setMaximumFractionDigits(8);
amount.setText(df.format(newValue.getValue(value)));
}
});
maxButton.setDisable(!isValidRecipientAddress());
sendController.utxoLabelSelectionProperty().addListener((observable, oldValue, newValue) -> {
maxButton.setText("Max" + newValue);
});
Optional<Tab> firstTab = sendController.getPaymentTabs().getTabs().stream().findFirst();
if(firstTab.isPresent()) {
PaymentController controller = (PaymentController)firstTab.get().getUserData();
String firstLabel = controller.label.getText();
label.setText(firstLabel);
}
addValidation(validationSupport);
}
private void addValidation(ValidationSupport validationSupport) {
this.validationSupport = validationSupport;
validationSupport.registerValidator(address, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress())
));
validationSupport.registerValidator(label, Validator.combine(
Validator.createEmptyValidator("Label is required")
));
validationSupport.registerValidator(amount, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", sendController.isInsufficientInputs()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold())
));
}
private boolean isValidRecipientAddress() {
try {
getRecipientAddress();
return true;
} catch (InvalidAddressException e) {
return false;
}
}
private Address getRecipientAddress() throws InvalidAddressException {
return Address.fromString(address.getText());
}
private Long getRecipientValueSats() {
return getRecipientValueSats(amountUnit.getSelectionModel().getSelectedItem());
}
private Long getRecipientValueSats(BitcoinUnit bitcoinUnit) {
if(amount.getText() != null && !amount.getText().isEmpty()) {
double fieldValue = Double.parseDouble(amount.getText().replaceAll(",", ""));
return bitcoinUnit.getSatsValue(fieldValue);
}
return null;
}
private void setRecipientValueSats(long recipientValue) {
amount.textProperty().removeListener(amountListener);
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
df.setMaximumFractionDigits(8);
amount.setText(df.format(amountUnit.getValue().getValue(recipientValue)));
amount.textProperty().addListener(amountListener);
}
private long getRecipientDustThreshold() {
Address address;
try {
address = getRecipientAddress();
} catch(InvalidAddressException e) {
address = new P2PKHAddress(new byte[20]);
}
TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript());
return address.getScriptType().getDustThreshold(txOutput, sendController.getFeeRate());
}
private void setFiatAmount(CurrencyRate currencyRate, Long amount) {
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
fiatAmount.set(currencyRate, amount);
}
}
public void revalidate() {
revalidate(amount, amountListener);
}
private void revalidate(TextField field, ChangeListener<String> listener) {
field.textProperty().removeListener(listener);
String amt = field.getText();
field.setText(amt + "0");
field.setText(amt);
field.textProperty().addListener(listener);
}
public boolean isValidPayment() {
try {
getPayment();
return true;
} catch(IllegalStateException e) {
return false;
}
}
public Payment getPayment() {
return getPayment(false);
}
public Payment getPayment(boolean sendAll) {
try {
Address address = getRecipientAddress();
Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats();
if(!label.getText().isEmpty() && value != null && value > getRecipientDustThreshold()) {
return new Payment(address, label.getText(), value, sendAll);
}
} catch(InvalidAddressException e) {
//ignore
}
throw new IllegalStateException("Invalid payment specified");
}
public void setPayment(Payment payment) {
if(getRecipientValueSats() == null || payment.getAmount() != getRecipientValueSats()) {
setRecipientValueSats(payment.getAmount());
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), payment.getAmount());
}
}
public void clear() {
address.setText("");
label.setText("");
amount.textProperty().removeListener(amountListener);
amount.setText("");
amount.textProperty().addListener(amountListener);
fiatAmount.setText("");
}
public void setMaxInput(ActionEvent event) {
UtxoSelector utxoSelector = sendController.getUtxoSelector();
if(utxoSelector == null) {
MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector();
sendController.utxoSelectorProperty().set(maxUtxoSelector);
}
try {
List<Payment> payments = new ArrayList<>();
for(Tab tab : sendController.getPaymentTabs().getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
if(controller != this) {
payments.add(controller.getPayment());
} else {
payments.add(getPayment(true));
}
}
sendController.updateTransaction(payments);
} catch(IllegalStateException e) {
//ignore, validation errors
}
}
public void scanQrAddress(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.uri != null) {
if(result.uri.getAddress() != null) {
address.setText(result.uri.getAddress().toString());
}
if(result.uri.getLabel() != null) {
label.setText(result.uri.getLabel());
}
if(result.uri.getAmount() != null) {
setRecipientValueSats(result.uri.getAmount());
}
sendController.updateTransaction();
}
}
}
public void addPayment(ActionEvent event) {
sendController.addPaymentTab();
}
public Button getAddPaymentButton() {
return addPaymentButton;
}
@Subscribe
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
BitcoinUnit unit = sendController.getBitcoinUnit(event.getBitcoinUnit());
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
}
@Subscribe
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
if(event.getExchangeSource() == ExchangeSource.NONE) {
fiatAmount.setCurrency(null);
fiatAmount.setBtcRate(0.0);
}
}
@Subscribe
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatAmount(event.getCurrencyRate(), getRecipientValueSats());
}
}

View file

@ -17,17 +17,18 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ExchangeSource;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.util.StringConverter;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
@ -36,6 +37,7 @@ import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URL;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
@ -51,22 +53,7 @@ public class SendController extends WalletFormController implements Initializabl
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
@FXML
private CopyableTextField address;
@FXML
private TextField label;
@FXML
private TextField amount;
@FXML
private ComboBox<BitcoinUnit> amountUnit;
@FXML
private FiatLabel fiatAmount;
@FXML
private Button maxButton;
private TabPane paymentTabs;
@FXML
private Slider targetBlocks;
@ -95,6 +82,8 @@ public class SendController extends WalletFormController implements Initializabl
@FXML
private Button createButton;
private StackPane tabHeader;
private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false);
private final ObjectProperty<UtxoSelector> utxoSelectorProperty = new SimpleObjectProperty<>(null);
@ -107,23 +96,7 @@ public class SendController extends WalletFormController implements Initializabl
private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false);
private final ChangeListener<String> amountListener = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if(utxoSelectorProperty.get() instanceof MaxUtxoSelector) {
utxoSelectorProperty.setValue(null);
}
Long recipientValueSats = getRecipientValueSats();
if(recipientValueSats != null) {
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), recipientValueSats);
} else {
fiatAmount.setText("");
}
updateTransaction();
}
};
private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty("");
private final ChangeListener<String> feeListener = new ChangeListener<>() {
@Override
@ -157,7 +130,10 @@ public class SendController extends WalletFormController implements Initializabl
targetBlocks.setTooltip(tooltip);
userFeeSet.set(false);
revalidate(amount, amountListener);
for(Tab tab : paymentTabs.getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
controller.revalidate();
}
updateTransaction();
}
};
@ -171,42 +147,44 @@ public class SendController extends WalletFormController implements Initializabl
@Override
public void initializeView() {
address.textProperty().addListener((observable, oldValue, newValue) -> {
revalidate(amount, amountListener);
maxButton.setDisable(!isValidRecipientAddress());
addValidation();
addPaymentTab();
Platform.runLater(() -> {
StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
if(stackPane != null) {
tabHeader = stackPane;
tabHeader.managedProperty().bind(tabHeader.visibleProperty());
tabHeader.setVisible(false);
paymentTabs.getStyleClass().remove("initial");
}
});
paymentTabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
if(tabHeader != null) {
tabHeader.setVisible(c.getList().size() > 1);
}
if(c.getList().size() > 1) {
if(!paymentTabs.getStyleClass().contains("multiple-tabs")) {
paymentTabs.getStyleClass().add("multiple-tabs");
}
paymentTabs.getTabs().forEach(tab -> tab.setClosable(true));
} else {
paymentTabs.getStyleClass().remove("multiple-tabs");
Tab remainingTab = paymentTabs.getTabs().get(0);
remainingTab.setClosable(false);
remainingTab.setText("1");
}
updateTransaction();
if(validationSupport != null) {
validationSupport.setErrorDecorationEnabled(true);
}
});
label.textProperty().addListener((observable, oldValue, newValue) -> {
createButton.setDisable(walletTransactionProperty.get() == null || newValue == null || newValue.isEmpty() || isInsufficientFeeRate());
});
amount.setTextFormatter(new CoinTextFormatter());
amount.textProperty().addListener(amountListener);
BitcoinUnit unit = Config.get().getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = getWalletForm().getWallet().getAutoUnit();
}
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
amountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
Long value = getRecipientValueSats(oldValue);
if(value != null) {
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
df.setMaximumFractionDigits(8);
amount.setText(df.format(newValue.getValue(value)));
}
});
maxButton.setDisable(!isValidRecipientAddress());
insufficientInputsProperty.addListener((observable, oldValue, newValue) -> {
revalidate(amount, amountListener);
for(Tab tab : paymentTabs.getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
controller.revalidate();
}
revalidate(fee, feeListener);
});
@ -244,6 +222,7 @@ public class SendController extends WalletFormController implements Initializabl
fee.setTextFormatter(new CoinTextFormatter());
fee.textProperty().addListener(feeListener);
BitcoinUnit unit = getBitcoinUnit(Config.get().getBitcoinUnit());
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
feeAmountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
Long value = getFeeValueSats(oldValue);
@ -265,6 +244,10 @@ public class SendController extends WalletFormController implements Initializabl
}
});
utxoLabelSelectionProperty.addListener((observable, oldValue, newValue) -> {
clearButton.setText("Clear" + newValue);
});
utxoSelectorProperty.addListener((observable, oldValue, utxoSelector) -> {
updateMaxClearButtons(utxoSelector, utxoFilterProperty.get());
});
@ -275,9 +258,10 @@ public class SendController extends WalletFormController implements Initializabl
walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> {
if(walletTransaction != null) {
if(getRecipientValueSats() == null || walletTransaction.getPayments().get(0).getAmount() != getRecipientValueSats()) {
setRecipientValueSats(walletTransaction.getPayments().get(0).getAmount());
setFiatAmount(AppController.getFiatCurrencyExchangeRate(), walletTransaction.getPayments().get(0).getAmount());
for(int i = 0; i < paymentTabs.getTabs().size(); i++) {
Payment payment = walletTransaction.getPayments().get(i);
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(i).getUserData();
controller.setPayment(payment);
}
double feeRate = walletTransaction.getFeeRate();
@ -291,24 +275,24 @@ public class SendController extends WalletFormController implements Initializabl
}
transactionDiagram.update(walletTransaction);
createButton.setDisable(walletTransaction == null || label.getText().isEmpty() || isInsufficientFeeRate());
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
});
}
addValidation();
public BitcoinUnit getBitcoinUnit(BitcoinUnit bitcoinUnit) {
BitcoinUnit unit = bitcoinUnit;
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = getWalletForm().getWallet().getAutoUnit();
}
return unit;
}
public ValidationSupport getValidationSupport() {
return validationSupport;
}
private void addValidation() {
validationSupport = new ValidationSupport();
validationSupport.registerValidator(address, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid Address", !newValue.isEmpty() && !isValidRecipientAddress())
));
validationSupport.registerValidator(label, Validator.combine(
Validator.createEmptyValidator("Label is required")
));
validationSupport.registerValidator(amount, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", insufficientInputsProperty.get()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold())
));
validationSupport.registerValidator(fee, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", userFeeSet.get() && insufficientInputsProperty.get()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0),
@ -316,20 +300,64 @@ public class SendController extends WalletFormController implements Initializabl
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.setErrorDecorationEnabled(false);
}
private void updateTransaction() {
updateTransaction(false);
public Tab addPaymentTab() {
Tab tab = getPaymentTab();
paymentTabs.getTabs().add(tab);
paymentTabs.getSelectionModel().select(tab);
return tab;
}
private void updateTransaction(boolean sendAll) {
public Tab getPaymentTab() {
Tab tab = new Tab(Integer.toString(paymentTabs.getTabs().size() + 1));
try {
Address recipientAddress = getRecipientAddress();
long recipientDustThreshold = getRecipientDustThreshold();
Long recipientAmount = sendAll ? Long.valueOf(recipientDustThreshold + 1) : getRecipientValueSats();
if(recipientAmount != null && recipientAmount > recipientDustThreshold && (!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0))) {
FXMLLoader paymentLoader = new FXMLLoader(AppController.class.getResource("wallet/payment.fxml"));
tab.setContent(paymentLoader.load());
PaymentController controller = paymentLoader.getController();
controller.setSendController(this);
controller.initializeView();
tab.setUserData(controller);
return tab;
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public List<Payment> getPayments() {
List<Payment> payments = new ArrayList<>();
for(Tab tab : paymentTabs.getTabs()) {
PaymentController controller = (PaymentController)tab.getUserData();
payments.add(controller.getPayment());
}
return payments;
}
public void updateTransaction() {
updateTransaction(null);
}
public void updateTransaction(boolean sendAll) {
try {
if(paymentTabs.getTabs().size() == 1) {
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
updateTransaction(List.of(controller.getPayment(sendAll)));
} else {
updateTransaction(null);
}
} catch(IllegalStateException e) {
//ignore
}
}
public void updateTransaction(List<Payment> transactionPayments) {
try {
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
Wallet wallet = getWalletForm().getWallet();
List<Payment> payments = List.of(new Payment(recipientAddress, label.getText(), recipientAmount, sendAll));
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
Integer currentBlockHeight = AppController.getCurrentBlockHeight();
boolean groupByAddress = Config.get().isGroupByAddress();
@ -340,9 +368,9 @@ public class SendController extends WalletFormController implements Initializabl
return;
}
} catch (InvalidAddressException e) {
} catch(InvalidAddressException | IllegalStateException e) {
//ignore
} catch (InsufficientFundsException e) {
} catch(InsufficientFundsException e) {
insufficientInputsProperty.set(true);
}
@ -355,7 +383,7 @@ public class SendController extends WalletFormController implements Initializabl
}
Wallet wallet = getWalletForm().getWallet();
long noInputsFee = wallet.getNoInputsFee(getRecipientAddress(), getFeeRate());
long noInputsFee = wallet.getNoInputsFee(getPayments(), getFeeRate());
long costOfChange = wallet.getCostOfChange(getFeeRate(), getMinimumFeeRate());
return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee));
@ -370,40 +398,6 @@ public class SendController extends WalletFormController implements Initializabl
return Collections.emptyList();
}
private boolean isValidRecipientAddress() {
try {
getRecipientAddress();
return true;
} catch (InvalidAddressException e) {
return false;
}
}
private Address getRecipientAddress() throws InvalidAddressException {
return Address.fromString(address.getText());
}
private Long getRecipientValueSats() {
return getRecipientValueSats(amountUnit.getSelectionModel().getSelectedItem());
}
private Long getRecipientValueSats(BitcoinUnit bitcoinUnit) {
if(amount.getText() != null && !amount.getText().isEmpty()) {
double fieldValue = Double.parseDouble(amount.getText().replaceAll(",", ""));
return bitcoinUnit.getSatsValue(fieldValue);
}
return null;
}
private void setRecipientValueSats(long recipientValue) {
amount.textProperty().removeListener(amountListener);
DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
df.setMaximumFractionDigits(8);
amount.setText(df.format(amountUnit.getValue().getValue(recipientValue)));
amount.textProperty().addListener(amountListener);
}
private Long getFeeValueSats() {
return getFeeValueSats(feeAmountUnit.getSelectionModel().getSelectedItem());
}
@ -464,7 +458,7 @@ public class SendController extends WalletFormController implements Initializabl
return retrievedFeeRates;
}
private Double getFeeRate() {
public Double getFeeRate() {
return getTargetBlocksFeeRates().get(getTargetBlocks());
}
@ -474,7 +468,7 @@ public class SendController extends WalletFormController implements Initializabl
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
private boolean isInsufficientFeeRate() {
public boolean isInsufficientFeeRate() {
return walletTransactionProperty.get() != null && walletTransactionProperty.get().getFeeRate() < AppController.getMinimumRelayFeeRate();
}
@ -486,87 +480,40 @@ public class SendController extends WalletFormController implements Initializabl
return targetBlocks.lookup(".thumb");
}
public void setMaxInput(ActionEvent event) {
UtxoSelector utxoSelector = utxoSelectorProperty.get();
if(utxoSelector == null) {
MaxUtxoSelector maxUtxoSelector = new MaxUtxoSelector();
utxoSelectorProperty.set(maxUtxoSelector);
}
updateTransaction(true);
}
private void setFiatAmount(CurrencyRate currencyRate, Long amount) {
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
fiatAmount.set(currencyRate, amount);
}
}
private void setFiatFeeAmount(CurrencyRate currencyRate, Long amount) {
if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
fiatFeeAmount.set(currencyRate, amount);
}
}
private long getRecipientDustThreshold() {
Address address;
try {
address = getRecipientAddress();
} catch(InvalidAddressException e) {
address = new P2PKHAddress(new byte[20]);
}
TransactionOutput txOutput = new TransactionOutput(new Transaction(), 1L, address.getOutputScript());
return address.getScriptType().getDustThreshold(txOutput, getFeeRate());
}
private void updateMaxClearButtons(UtxoSelector utxoSelector, UtxoFilter utxoFilter) {
if(utxoSelector instanceof PresetUtxoSelector) {
PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
int num = presetUtxoSelector.getPresetUtxos().size();
String selection = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " selected)";
maxButton.setText("Max" + selection);
clearButton.setText("Clear" + selection);
utxoLabelSelectionProperty.set(selection);
} else if(utxoFilter instanceof ExcludeUtxoFilter) {
ExcludeUtxoFilter excludeUtxoFilter = (ExcludeUtxoFilter)utxoFilter;
int num = excludeUtxoFilter.getExcludedUtxos().size();
String exclusion = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " excluded)";
maxButton.setText("Max" + exclusion);
clearButton.setText("Clear" + exclusion);
utxoLabelSelectionProperty.set(exclusion);
} else {
maxButton.setText("Max");
clearButton.setText("Clear");
}
}
public void scanQrAddress(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.uri != null) {
if(result.uri.getAddress() != null) {
address.setText(result.uri.getAddress().toString());
}
if(result.uri.getLabel() != null) {
label.setText(result.uri.getLabel());
}
if(result.uri.getAmount() != null) {
setRecipientValueSats(result.uri.getAmount());
}
}
utxoLabelSelectionProperty.set("");
}
}
public void clear(ActionEvent event) {
address.setText("");
label.setText("");
amount.textProperty().removeListener(amountListener);
amount.setText("");
amount.textProperty().addListener(amountListener);
fiatAmount.setText("");
boolean firstTab = true;
for(Iterator<Tab> iterator = paymentTabs.getTabs().iterator(); iterator.hasNext(); ) {
PaymentController controller = (PaymentController)iterator.next().getUserData();
if(firstTab) {
controller.clear();
firstTab = false;
} else {
EventManager.get().unregister(controller);
iterator.remove();
}
}
fee.textProperty().removeListener(feeListener);
fee.setText("");
@ -584,6 +531,46 @@ public class SendController extends WalletFormController implements Initializabl
validationSupport.setErrorDecorationEnabled(false);
}
public UtxoSelector getUtxoSelector() {
return utxoSelectorProperty.get();
}
public ObjectProperty<UtxoSelector> utxoSelectorProperty() {
return utxoSelectorProperty;
}
public boolean isInsufficientInputs() {
return insufficientInputsProperty.get();
}
public BooleanProperty insufficientInputsProperty() {
return insufficientInputsProperty;
}
public WalletTransaction getWalletTransaction() {
return walletTransactionProperty.get();
}
public ObjectProperty<WalletTransaction> walletTransactionProperty() {
return walletTransactionProperty;
}
public String getUtxoLabelSelection() {
return utxoLabelSelectionProperty.get();
}
public StringProperty utxoLabelSelectionProperty() {
return utxoLabelSelectionProperty;
}
public TabPane getPaymentTabs() {
return paymentTabs;
}
public Button getCreateButton() {
return createButton;
}
private void revalidate(TextField field, ChangeListener<String> listener) {
field.textProperty().removeListener(listener);
String amt = field.getText();
@ -605,7 +592,7 @@ public class SendController extends WalletFormController implements Initializabl
addWalletTransactionNodes();
createdWalletTransactionProperty.set(walletTransactionProperty.get());
PSBT psbt = walletTransactionProperty.get().createPSBT();
EventManager.get().post(new ViewPSBTEvent(label.getText(), psbt));
EventManager.get().post(new ViewPSBTEvent(walletTransactionProperty.get().getPayments().get(0).getLabel(), psbt));
}
private void addWalletTransactionNodes() {
@ -683,25 +670,20 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
BitcoinUnit unit = event.getBitcoinUnit();
if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
unit = getWalletForm().getWallet().getAutoUnit();
}
amountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
BitcoinUnit unit = getBitcoinUnit(event.getBitcoinUnit());
feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
}
@Subscribe
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
if(event.getExchangeSource() == ExchangeSource.NONE) {
fiatAmount.setCurrency(null);
fiatAmount.setBtcRate(0.0);
fiatFeeAmount.setCurrency(null);
fiatFeeAmount.setBtcRate(0.0);
}
}
@Subscribe
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatAmount(event.getCurrencyRate(), getRecipientValueSats());
setFiatFeeAmount(event.getCurrencyRate(), getFeeValueSats());
}

View file

@ -153,3 +153,9 @@
-fx-border-color: derive(-fx-base, -2%);
/*-fx-border-width: 1;*/
}
.root .multiple-tabs {
-fx-background-color: derive(-fx-background, -4%);
-fx-border-width: 1px 0px 1px 0px;
-fx-border-color: derive(-fx-background, -10%);
}

View file

@ -27,6 +27,10 @@
-fx-padding: 3 5;
}
.form .fieldset.header .legend {
-fx-padding: 0 0 15px 0;
}
.id, .fixed-width {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import tornadofx.control.Form?>
<?import tornadofx.control.Fieldset?>
<?import tornadofx.control.Field?>
<?import com.sparrowwallet.sparrow.control.CopyableTextField?>
<?import javafx.collections.FXCollections?>
<?import com.sparrowwallet.sparrow.control.FiatLabel?>
<?import com.sparrowwallet.drongo.BitcoinUnit?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import javafx.geometry.Insets?>
<GridPane styleClass="send-form" hgap="10.0" vgap="10.0" stylesheets="@payment.css, @send.css, @wallet.css, @../script.css, @../general.css" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.PaymentController">
<padding>
<Insets top="10.0" bottom="10.0" />
</padding>
<columnConstraints>
<ColumnConstraints prefWidth="410" />
<ColumnConstraints prefWidth="200" />
<ColumnConstraints prefWidth="105" />
</columnConstraints>
<rowConstraints>
<RowConstraints />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2">
<Fieldset inputGrow="ALWAYS">
<Field text="Pay to:">
<CopyableTextField fx:id="address" styleClass="address-text-field"/>
</Field>
<Field text="Label:">
<TextField fx:id="label" />
</Field>
<Field text="Amount:">
<TextField fx:id="amount" styleClass="amount-field" />
<ComboBox fx:id="amountUnit" styleClass="amount-unit">
<items>
<FXCollections fx:factory="observableArrayList">
<BitcoinUnit fx:constant="BTC" />
<BitcoinUnit fx:constant="SATOSHIS" />
</FXCollections>
</items>
</ComboBox>
<Label style="-fx-pref-width: 15" />
<FiatLabel fx:id="fiatAmount" />
<Region style="-fx-pref-width: 20" />
<Button fx:id="maxButton" text="Max" onAction="#setMaxInput" />
</Field>
</Fieldset>
</Form>
<Form GridPane.columnIndex="2" GridPane.rowIndex="0">
<Fieldset inputGrow="ALWAYS" style="-fx-padding: 2 0 0 0">
<HBox>
<Button text="" onAction="#scanQrAddress" prefHeight="30">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>
</Button>
<Region HBox.hgrow="ALWAYS" />
<Button fx:id="addPaymentButton" text="Add" onAction="#addPayment" prefHeight="30">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="PLUS" />
</graphic>
</Button>
<Region HBox.hgrow="ALWAYS" />
</HBox>
</Fieldset>
</Form>
</GridPane>

View file

@ -33,7 +33,7 @@
<RowConstraints />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="ALWAYS" text="Receive">
<Fieldset inputGrow="ALWAYS" text="Receive" styleClass="header">
<Field text="Address:">
<CopyableTextField fx:id="address" styleClass="address-text-field" editable="false"/>
</Field>

View file

@ -1,3 +1,7 @@
.title-form .fieldset {
-fx-padding: 0 0 0 0;
}
.form .fieldset:horizontal .field {
-fx-pref-height: 40px;
}
@ -6,6 +10,24 @@
-fx-pref-width: 90px;
}
#paymentTabs {
-fx-max-height: 154px;
}
.initial .tab-header-area {
visibility: hidden;
}
.multiple-tabs {
-fx-background-color: derive(-fx-background, 20%);
-fx-border-width: 1px 0px 1px 0px;
-fx-border-color: derive(-fx-background, -5%);
}
.multiple-tabs .send-form {
-fx-padding: 9px 0px 9px 0px;
}
.amount-field {
-fx-pref-width: 140px;
-fx-min-width: 140px;
@ -51,7 +73,7 @@
-fx-stroke: transparent;
}
#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip {
#transactionDiagram .input-label, #transactionDiagram .recipient-label, #transactionDiagram .change-label, #transactionDiagram .fee-tooltip, #transactionDiagram .transaction-tooltip {
-fx-font-size: 13px;
-fx-font-family: 'Roboto Mono';
}

View file

@ -37,40 +37,16 @@
<rowConstraints>
<RowConstraints />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2">
<Fieldset inputGrow="ALWAYS" text="Send">
<Field text="Pay to:">
<CopyableTextField fx:id="address" styleClass="address-text-field"/>
</Field>
<Field text="Label:">
<TextField fx:id="label" />
</Field>
<Field text="Amount:">
<TextField fx:id="amount" styleClass="amount-field" />
<ComboBox fx:id="amountUnit" styleClass="amount-unit">
<items>
<FXCollections fx:factory="observableArrayList">
<BitcoinUnit fx:constant="BTC" />
<BitcoinUnit fx:constant="SATOSHIS" />
</FXCollections>
</items>
</ComboBox>
<Label style="-fx-pref-width: 15" />
<FiatLabel fx:id="fiatAmount" />
<Region style="-fx-pref-width: 20" />
<Button fx:id="maxButton" text="Max" onAction="#setMaxInput" />
</Field>
</Fieldset>
</Form>
<Form GridPane.columnIndex="2" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="" style="-fx-padding: 2 0 0 0">
<Button text="Scan QR" onAction="#scanQrAddress" prefHeight="30">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>
</Button>
</Fieldset>
</Form>
<BorderPane GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="3">
<top>
<Form styleClass="title-form">
<Fieldset inputGrow="ALWAYS" text="Send"/>
</Form>
</top>
<center>
<TabPane fx:id="paymentTabs" side="RIGHT" styleClass="initial" />
</center>
</BorderPane>
<Form GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="Fee">
<Field text="Block target">

View file

@ -28,7 +28,7 @@
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Settings">
<Fieldset inputGrow="SOMETIMES" text="Settings" styleClass="header">
<Field text="Policy Type:">
<ComboBox fx:id="policyType">
<items>

View file

@ -30,7 +30,7 @@
<RowConstraints />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="Transactions">
<Fieldset inputGrow="SOMETIMES" text="Transactions" styleClass="header">
<Field text="Balance:">
<CoinLabel fx:id="balance"/>
</Field>