From 571c515a46f1924635c3802743de74df9e956fa5 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 30 Jun 2020 09:23:55 +0200 Subject: [PATCH] send controller initial, fee rates support --- drongo | 2 +- .../sparrowwallet/sparrow/AppController.java | 14 +- .../sparrowwallet/sparrow/BitcoinUnit.java | 35 ++++ .../sparrow/control/FeeRatesChart.java | 54 ++++++ .../sparrow/control/UtxosChart.java | 7 +- .../sparrow/event/ConnectionEvent.java | 6 +- .../sparrow/event/FeeRatesUpdatedEvent.java | 15 ++ .../sparrow/io/ElectrumServer.java | 40 ++++- .../sparrow/wallet/SendController.java | 164 ++++++++++++++++++ .../sparrowwallet/sparrow/wallet/receive.css | 13 +- .../com/sparrowwallet/sparrow/wallet/send.css | 33 ++++ .../sparrowwallet/sparrow/wallet/send.fxml | 92 ++++++++++ .../sparrowwallet/sparrow/wallet/wallet.css | 4 + 13 files changed, 462 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/BitcoinUnit.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/FeeRatesChart.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java create mode 100644 src/main/resources/com/sparrowwallet/sparrow/wallet/send.css create mode 100644 src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml diff --git a/drongo b/drongo index 24cde9d0..c4dd1cb9 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 24cde9d073da636fbc2150b7abbd50b48342e040 +Subproject commit c4dd1cb9dd40a7a16829a00f45acbd55f63d9895 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index 15582133..7b26b63b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -88,10 +88,12 @@ public class AppController implements Initializable { private ElectrumServer.ConnectionService connectionService; - public static Integer currentBlockHeight; + private static Integer currentBlockHeight; public static boolean showTxHexProperty; + private static Map targetBlockFeeRates; + @Override public void initialize(URL location, ResourceBundle resources) { EventManager.get().register(this); @@ -342,6 +344,10 @@ public class AppController implements Initializable { return currentBlockHeight; } + public static Map getTargetBlockFeeRates() { + return targetBlockFeeRates; + } + public static void showErrorDialog(String title, String content) { Alert alert = new Alert(Alert.AlertType.ERROR); alert.setTitle(title); @@ -764,6 +770,7 @@ public class AppController implements Initializable { @Subscribe public void newConnection(ConnectionEvent event) { currentBlockHeight = event.getBlockHeight(); + targetBlockFeeRates = event.getTargetBlockFeeRates(); String banner = event.getServerBanner(); String status = "Connected: " + (banner == null ? "Server" : banner.split(System.lineSeparator(), 2)[0]) + " at height " + event.getBlockHeight(); EventManager.get().post(new StatusEvent(status)); @@ -783,6 +790,11 @@ public class AppController implements Initializable { EventManager.get().post(new StatusEvent(status)); } + @Subscribe + public void feesUpdated(FeeRatesUpdatedEvent event) { + targetBlockFeeRates = event.getTargetBlockFeeRates(); + } + @Subscribe public void viewTransaction(ViewTransactionEvent event) { Tab tab = addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex()); diff --git a/src/main/java/com/sparrowwallet/sparrow/BitcoinUnit.java b/src/main/java/com/sparrowwallet/sparrow/BitcoinUnit.java new file mode 100644 index 00000000..6cf0d01e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/BitcoinUnit.java @@ -0,0 +1,35 @@ +package com.sparrowwallet.sparrow; + +import com.sparrowwallet.drongo.protocol.Transaction; + +public enum BitcoinUnit { + BTC("BTC") { + @Override + public long getSatsValue(double unitValue) { + return (long)(unitValue * Transaction.SATOSHIS_PER_BITCOIN); + } + }, + SATOSHIS("sats") { + @Override + public long getSatsValue(double unitValue) { + return (long)unitValue; + } + }; + + private final String label; + + BitcoinUnit(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + + public abstract long getSatsValue(double unitValue); + + @Override + public String toString() { + return label; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FeeRatesChart.java b/src/main/java/com/sparrowwallet/sparrow/control/FeeRatesChart.java new file mode 100644 index 00000000..79da0549 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/FeeRatesChart.java @@ -0,0 +1,54 @@ +package com.sparrowwallet.sparrow.control; + +import javafx.beans.NamedArg; +import javafx.scene.Node; +import javafx.scene.chart.Axis; +import javafx.scene.chart.LineChart; +import javafx.scene.chart.XYChart; + +import java.util.Map; + +public class FeeRatesChart extends LineChart { + private XYChart.Series feeRateSeries; + private Integer selectedTargetBlocks; + + public FeeRatesChart(@NamedArg("xAxis") Axis xAxis, @NamedArg("yAxis") Axis yAxis) { + super(xAxis, yAxis); + } + + public void initialize() { + feeRateSeries = new XYChart.Series<>(); + getData().add(feeRateSeries); + } + + public void update(Map targetBlocksFeeRates) { + feeRateSeries.getData().clear(); + + for(Integer targetBlocks : targetBlocksFeeRates.keySet()) { + XYChart.Data data = new XYChart.Data<>(Integer.toString(targetBlocks), targetBlocksFeeRates.get(targetBlocks)); + feeRateSeries.getData().add(data); + } + + if(selectedTargetBlocks != null) { + select(selectedTargetBlocks); + } + } + + public void select(Integer targetBlocks) { + Node selectedSymbol = lookup(".chart-line-symbol.selected"); + if(selectedSymbol != null) { + selectedSymbol.getStyleClass().remove("selected"); + } + + for(int i = 0; i < feeRateSeries.getData().size(); i++) { + XYChart.Data data = feeRateSeries.getData().get(i); + Node symbol = lookup(".chart-line-symbol.data" + i); + if(symbol != null) { + if(data.getXValue().equals(targetBlocks.toString())) { + symbol.getStyleClass().add("selected"); + selectedTargetBlocks = targetBlocks; + } + } + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/UtxosChart.java b/src/main/java/com/sparrowwallet/sparrow/control/UtxosChart.java index 30492d5f..561c6f55 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/UtxosChart.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/UtxosChart.java @@ -64,6 +64,11 @@ public class UtxosChart extends BarChart { } public void select(Entry entry) { + Node selectedBar = lookup(".chart-bar.selected"); + if(selectedBar != null) { + selectedBar.getStyleClass().remove("selected"); + } + for(int i = 0; i < utxoSeries.getData().size(); i++) { XYChart.Data data = utxoSeries.getData().get(i); Node bar = lookup(".data" + i); @@ -71,8 +76,6 @@ public class UtxosChart extends BarChart { if(data.getExtraValue() != null && data.getExtraValue().equals(entry)) { bar.getStyleClass().add("selected"); this.selectedEntry = entry; - } else { - bar.getStyleClass().remove("selected"); } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/ConnectionEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/ConnectionEvent.java index e87f4bf5..0ca3665b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/event/ConnectionEvent.java +++ b/src/main/java/com/sparrowwallet/sparrow/event/ConnectionEvent.java @@ -3,14 +3,16 @@ package com.sparrowwallet.sparrow.event; import com.sparrowwallet.drongo.protocol.BlockHeader; import java.util.List; +import java.util.Map; -public class ConnectionEvent { +public class ConnectionEvent extends FeeRatesUpdatedEvent { private final List serverVersion; private final String serverBanner; private final int blockHeight; private final BlockHeader blockHeader; - public ConnectionEvent(List serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader) { + public ConnectionEvent(List serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map targetBlockFeeRates) { + super(targetBlockFeeRates); this.serverVersion = serverVersion; this.serverBanner = serverBanner; this.blockHeight = blockHeight; diff --git a/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java new file mode 100644 index 00000000..03f3b902 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/FeeRatesUpdatedEvent.java @@ -0,0 +1,15 @@ +package com.sparrowwallet.sparrow.event; + +import java.util.Map; + +public class FeeRatesUpdatedEvent { + private final Map targetBlockFeeRates; + + public FeeRatesUpdatedEvent(Map targetBlockFeeRates) { + this.targetBlockFeeRates = targetBlockFeeRates; + } + + public Map getTargetBlockFeeRates() { + return targetBlockFeeRates; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java index b0d3b1b3..f21d4906 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ElectrumServer.java @@ -16,7 +16,9 @@ import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppController; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.ConnectionEvent; +import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent; import com.sparrowwallet.sparrow.event.NewBlockEvent; +import com.sparrowwallet.sparrow.wallet.SendController; import javafx.application.Platform; import javafx.concurrent.ScheduledService; import javafx.concurrent.Service; @@ -505,6 +507,23 @@ public class ElectrumServer { return transactionMap; } + public Map getFeeEstimates(List targetBlocks) throws ServerException { + JsonRpcClient client = new JsonRpcClient(getTransport()); + BatchRequestBuilder batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(Double.class); + for(Integer targetBlock : targetBlocks) { + batchRequest.add(targetBlock, "blockchain.estimatefee", targetBlock); + } + + Map targetBlocksFeeRatesBtcKb = batchRequest.execute(); + + Map targetBlocksFeeRatesSats = new TreeMap<>(); + for(Integer target : targetBlocksFeeRatesBtcKb.keySet()) { + targetBlocksFeeRatesSats.put(target, targetBlocksFeeRatesBtcKb.get(target) * Transaction.SATOSHIS_PER_BITCOIN / 1024); + } + + return targetBlocksFeeRatesSats; + } + private String getScriptHash(Wallet wallet, WalletNode node) { byte[] hash = Sha256Hash.hash(wallet.getOutputScript(node).getProgram()); byte[] reversed = Utils.reverseBytes(hash); @@ -802,15 +821,18 @@ public class ElectrumServer { } } - public static class ConnectionService extends ScheduledService implements Thread.UncaughtExceptionHandler { + public static class ConnectionService extends ScheduledService implements Thread.UncaughtExceptionHandler { + private static final int FEE_RATES_PERIOD = 5 * 60 * 1000; + private boolean firstCall = true; private Thread reader; private Throwable lastReaderException; + private long feeRatesRetrievedAt; @Override - protected Task createTask() { + protected Task createTask() { return new Task<>() { - protected ConnectionEvent call() throws ServerException { + protected FeeRatesUpdatedEvent call() throws ServerException { ElectrumServer electrumServer = new ElectrumServer(); if(firstCall) { electrumServer.connect(); @@ -826,10 +848,20 @@ public class ElectrumServer { BlockHeaderTip tip = electrumServer.subscribeBlockHeaders(); String banner = electrumServer.getServerBanner(); - return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader()); + Map blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE); + feeRatesRetrievedAt = System.currentTimeMillis(); + + return new ConnectionEvent(serverVersion, banner, tip.height, tip.getBlockHeader(), blockTargetFeeRates); } else { if(reader.isAlive()) { electrumServer.ping(); + + long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt; + if(elapsed > FEE_RATES_PERIOD) { + Map blockTargetFeeRates = electrumServer.getFeeEstimates(SendController.TARGET_BLOCKS_RANGE); + feeRatesRetrievedAt = System.currentTimeMillis(); + return new FeeRatesUpdatedEvent(blockTargetFeeRates); + } } else { firstCall = true; throw new ServerException("Connection to server failed", lastReaderException); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java new file mode 100644 index 00000000..c7209922 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -0,0 +1,164 @@ +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.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.event.FeeRatesUpdatedEvent; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.fxml.Initializable; +import javafx.scene.control.*; +import javafx.util.StringConverter; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +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; + +public class SendController extends WalletFormController implements Initializable { + public static final List TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50); + + @FXML + private CopyableTextField address; + + @FXML + private TextField label; + + @FXML + private TextField amount; + + @FXML + private ComboBox amountUnit; + + @FXML + private Slider targetBlocks; + + @FXML + private CopyableLabel feeRate; + + @FXML + private TextField fee; + + @FXML + private ComboBox feeAmountUnit; + + @FXML + private FeeRatesChart feeRatesChart; + + @Override + public void initialize(URL location, ResourceBundle resources) { + EventManager.get().register(this); + } + + @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()); + + amountUnit.getSelectionModel().select(0); + + targetBlocks.setMin(0); + targetBlocks.setMax(TARGET_BLOCKS_RANGE.size() - 1); + targetBlocks.setMajorTickUnit(1); + targetBlocks.setMinorTickCount(0); + targetBlocks.setLabelFormatter(new StringConverter() { + @Override + public String toString(Double object) { + return Integer.toString(TARGET_BLOCKS_RANGE.get(object.intValue())); + } + + @Override + public Double fromString(String string) { + return (double)TARGET_BLOCKS_RANGE.indexOf(Integer.valueOf(string)); + } + }); + targetBlocks.valueProperty().addListener((observable, oldValue, newValue) -> { + Map targetBlocksFeeRates = getTargetBlocksFeeRates(); + Integer target = getTargetBlocks(); + + if(targetBlocksFeeRates != null) { + setFeeRate(targetBlocksFeeRates.get(target)); + feeRatesChart.select(target); + } else { + feeRate.setText("Unknown"); + } + + Tooltip tooltip = new Tooltip("Target confirmation within " + target + " blocks"); + targetBlocks.setTooltip(tooltip); + + //TODO: Set fee based on tx size + }); + + feeAmountUnit.getSelectionModel().select(1); + + feeRatesChart.initialize(); + Map targetBlocksFeeRates = getTargetBlocksFeeRates(); + if(targetBlocksFeeRates != null) { + feeRatesChart.update(targetBlocksFeeRates); + } else { + feeRate.setText("Unknown"); + } + + setTargetBlocks(5); + } + + private boolean isValidAddress() { + try { + getAddress(); + } catch (InvalidAddressException e) { + return false; + } + + return true; + } + + private Address getAddress() throws InvalidAddressException { + return Address.fromString(address.getText()); + } + + private Integer getTargetBlocks() { + int index = (int)targetBlocks.getValue(); + return TARGET_BLOCKS_RANGE.get(index); + } + + private void setTargetBlocks(Integer target) { + int index = TARGET_BLOCKS_RANGE.indexOf(target); + targetBlocks.setValue(index); + feeRatesChart.select(target); + } + + private Map getTargetBlocksFeeRates() { + return AppController.getTargetBlockFeeRates(); + } + + private void setFeeRate(Double feeRateAmt) { + feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vByte"); + } + + public void setMaxInput(ActionEvent event) { + + } + + @Subscribe + public void feeRatesUpdated(FeeRatesUpdatedEvent event) { + feeRatesChart.update(event.getTargetBlockFeeRates()); + feeRatesChart.select(getTargetBlocks()); + setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks())); + } +} diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css index 611d444f..ddfd3c32 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/receive.css @@ -1,5 +1,9 @@ -.address-text-field { - -fx-font-family: Courier; +.form .fieldset:horizontal .field { + -fx-pref-height: 40px; +} + +.receive-form .form .fieldset:horizontal .label-container { + -fx-pref-width: 90px; } .qr-code { @@ -7,11 +11,6 @@ -fx-padding: 20; } -.receive-form .form .fieldset:horizontal .label-container { - -fx-pref-width: 90px; - -fx-pref-height: 25px; -} - #lastUsedField .input-container { -fx-alignment: center-left; } \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css new file mode 100644 index 00000000..25d5c4a4 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css @@ -0,0 +1,33 @@ +.form .fieldset:horizontal .field { + -fx-pref-height: 40px; +} + +.send-form .form .fieldset:horizontal .label-container { + -fx-pref-width: 90px; +} + +.amount-field { + -fx-max-width: 150px; +} + +#feeRatesChart { + -fx-max-width: 350px; + -fx-max-height: 130px; +} + +.default-color0.chart-series-line { + -fx-stroke: rgba(105, 108, 119, 0.6); + -fx-stroke-width: 1px; +} + +.chart-line-symbol { + -fx-background-color: rgba(30, 136, 207, 0); +} + +.chart-line-symbol.selected { + -fx-background-color: rgba(30, 136, 207, 0.6); +} + +#feeRateField .input-container { + -fx-alignment: center-left; +} diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml new file mode 100644 index 00000000..0595ccb4 --- /dev/null +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + +
+
+
diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css index a5aa6b7a..dc475fe6 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/wallet.css @@ -108,4 +108,8 @@ .unused-check { -fx-text-fill: #50a14f; +} + +.address-text-field { + -fx-font-family: Courier; } \ No newline at end of file