send controller initial, fee rates support

This commit is contained in:
Craig Raw 2020-06-30 09:23:55 +02:00
parent 1d0b66c45a
commit 571c515a46
13 changed files with 462 additions and 17 deletions

2
drongo

@ -1 +1 @@
Subproject commit 24cde9d073da636fbc2150b7abbd50b48342e040
Subproject commit c4dd1cb9dd40a7a16829a00f45acbd55f63d9895

View file

@ -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<Integer, Double> 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<Integer, Double> 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());

View file

@ -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;
}
}

View file

@ -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<String, Number> {
private XYChart.Series<String, Number> feeRateSeries;
private Integer selectedTargetBlocks;
public FeeRatesChart(@NamedArg("xAxis") Axis<String> xAxis, @NamedArg("yAxis") Axis<Number> yAxis) {
super(xAxis, yAxis);
}
public void initialize() {
feeRateSeries = new XYChart.Series<>();
getData().add(feeRateSeries);
}
public void update(Map<Integer, Double> targetBlocksFeeRates) {
feeRateSeries.getData().clear();
for(Integer targetBlocks : targetBlocksFeeRates.keySet()) {
XYChart.Data<String, Number> 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<String, Number> 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;
}
}
}
}
}

View file

@ -64,6 +64,11 @@ public class UtxosChart extends BarChart<String, Number> {
}
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<String, Number> data = utxoSeries.getData().get(i);
Node bar = lookup(".data" + i);
@ -71,8 +76,6 @@ public class UtxosChart extends BarChart<String, Number> {
if(data.getExtraValue() != null && data.getExtraValue().equals(entry)) {
bar.getStyleClass().add("selected");
this.selectedEntry = entry;
} else {
bar.getStyleClass().remove("selected");
}
}
}

View file

@ -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<String> serverVersion;
private final String serverBanner;
private final int blockHeight;
private final BlockHeader blockHeader;
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader) {
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates) {
super(targetBlockFeeRates);
this.serverVersion = serverVersion;
this.serverBanner = serverBanner;
this.blockHeight = blockHeight;

View file

@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import java.util.Map;
public class FeeRatesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates) {
this.targetBlockFeeRates = targetBlockFeeRates;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
}

View file

@ -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<Integer, Double> getFeeEstimates(List<Integer> targetBlocks) throws ServerException {
JsonRpcClient client = new JsonRpcClient(getTransport());
BatchRequestBuilder<Integer, Double> batchRequest = client.createBatchRequest().keysType(Integer.class).returnType(Double.class);
for(Integer targetBlock : targetBlocks) {
batchRequest.add(targetBlock, "blockchain.estimatefee", targetBlock);
}
Map<Integer, Double> targetBlocksFeeRatesBtcKb = batchRequest.execute();
Map<Integer, Double> 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<ConnectionEvent> implements Thread.UncaughtExceptionHandler {
public static class ConnectionService extends ScheduledService<FeeRatesUpdatedEvent> 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<ConnectionEvent> createTask() {
protected Task<FeeRatesUpdatedEvent> 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<Integer, Double> 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<Integer, Double> 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);

View file

@ -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<Integer> 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<BitcoinUnit> amountUnit;
@FXML
private Slider targetBlocks;
@FXML
private CopyableLabel feeRate;
@FXML
private TextField fee;
@FXML
private ComboBox<BitcoinUnit> 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<Double>() {
@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<Integer, Double> 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<Integer, Double> 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<Integer, Double> 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()));
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,92 @@
<?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.Fieldset?>
<?import tornadofx.control.Form?>
<?import tornadofx.control.Field?>
<?import com.sparrowwallet.sparrow.control.CopyableTextField?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.CopyableLabel?>
<?import javafx.collections.FXCollections?>
<?import com.sparrowwallet.drongo.policy.PolicyType?>
<?import com.sparrowwallet.sparrow.BitcoinUnit?>
<?import com.sparrowwallet.sparrow.control.FeeRatesChart?>
<?import javafx.scene.chart.CategoryAxis?>
<?import javafx.scene.chart.NumberAxis?>
<BorderPane stylesheets="@send.css, @wallet.css, @../script.css, @../general.css" styleClass="wallet-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.wallet.SendController">
<center>
<GridPane styleClass="send-form" hgap="10.0" vgap="10.0">
<padding>
<Insets left="25.0" right="25.0" top="25.0" />
</padding>
<columnConstraints>
<ColumnConstraints percentWidth="50" />
<ColumnConstraints percentWidth="30" />
<ColumnConstraints percentWidth="20" />
</columnConstraints>
<rowConstraints>
<RowConstraints />
</rowConstraints>
<Form GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2">
<Fieldset inputGrow="SOMETIMES" 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">
<items>
<FXCollections fx:factory="observableArrayList">
<BitcoinUnit fx:constant="BTC" />
<BitcoinUnit fx:constant="SATOSHIS" />
</FXCollections>
</items>
</ComboBox>
<Region style="-fx-pref-width: 20" />
<Button fx:id="maxButton" text="Max" onAction="#setMaxInput" />
</Field>
</Fieldset>
</Form>
<Form GridPane.columnIndex="0" GridPane.rowIndex="1">
<Fieldset inputGrow="SOMETIMES" text="Fee">
<Field text="Block Target:">
<Slider fx:id="targetBlocks" snapToTicks="true" showTickLabels="true" showTickMarks="true" />
</Field>
<Field fx:id="feeRateField" text="Rate:">
<CopyableLabel fx:id="feeRate" />
</Field>
<Field text="Fee:">
<TextField fx:id="fee" styleClass="amount-field"/>
<ComboBox fx:id="feeAmountUnit">
<items>
<FXCollections fx:factory="observableArrayList">
<BitcoinUnit fx:constant="BTC" />
<BitcoinUnit fx:constant="SATOSHIS" />
</FXCollections>
</items>
</ComboBox>
</Field>
</Fieldset>
</Form>
<AnchorPane GridPane.columnIndex="1" GridPane.rowIndex="1" GridPane.columnSpan="2">
<FeeRatesChart fx:id="feeRatesChart" legendVisible="false" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="20" animated="false">
<xAxis>
<CategoryAxis side="BOTTOM" />
</xAxis>
<yAxis>
<NumberAxis side="LEFT" />
</yAxis>
</FeeRatesChart>
</AnchorPane>
</GridPane>
</center>
</BorderPane>

View file

@ -108,4 +108,8 @@
.unused-check {
-fx-text-fill: #50a14f;
}
.address-text-field {
-fx-font-family: Courier;
}