mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2025-11-05 11:56:37 +00:00
support creating transactions with the minimum relay fee rate configured by the user or set by the connected server
This commit is contained in:
parent
c7e9a0a161
commit
9dcf210762
10 changed files with 167 additions and 38 deletions
2
drongo
2
drongo
|
|
@ -1 +1 @@
|
||||||
Subproject commit e1f2ce41ad2ca8771c6e79eaa488b12295ac6b0b
|
Subproject commit 2a456dd6027937705be7f6153b5a0a0eea757377
|
||||||
|
|
@ -91,8 +91,7 @@ public class AppServices {
|
||||||
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
|
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
|
||||||
|
|
||||||
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
|
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
|
||||||
public static final List<Long> LONG_FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L, 2048L, 4096L, 8192L);
|
private static final List<Double> LONG_FEE_RATES_RANGE = List.of(1d, 2d, 4d, 8d, 16d, 32d, 64d, 128d, 256d, 512d, 1024d, 2048d, 4096d, 8192d);
|
||||||
public static final List<Long> FEE_RATES_RANGE = LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
|
|
||||||
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
|
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
|
||||||
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
|
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
|
||||||
|
|
||||||
|
|
@ -142,6 +141,8 @@ public class AppServices {
|
||||||
|
|
||||||
private static Double minimumRelayFeeRate;
|
private static Double minimumRelayFeeRate;
|
||||||
|
|
||||||
|
private static Double serverMinimumRelayFeeRate;
|
||||||
|
|
||||||
private static CurrencyRate fiatCurrencyExchangeRate;
|
private static CurrencyRate fiatCurrencyExchangeRate;
|
||||||
|
|
||||||
private static List<Device> devices;
|
private static List<Device> devices;
|
||||||
|
|
@ -211,6 +212,7 @@ public class AppServices {
|
||||||
preventSleepService = createPreventSleepService();
|
preventSleepService = createPreventSleepService();
|
||||||
|
|
||||||
onlineProperty.addListener(onlineServicesListener);
|
onlineProperty.addListener(onlineServicesListener);
|
||||||
|
minimumRelayFeeRate = getConfiguredMinimumRelayFeeRate(config);
|
||||||
|
|
||||||
if(config.getMode() == Mode.ONLINE) {
|
if(config.getMode() == Mode.ONLINE) {
|
||||||
if(config.requiresInternalTor()) {
|
if(config.requiresInternalTor()) {
|
||||||
|
|
@ -750,6 +752,26 @@ public class AppServices {
|
||||||
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
|
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<Double> getLongFeeRatesRange() {
|
||||||
|
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
|
return LONG_FEE_RATES_RANGE;
|
||||||
|
} else {
|
||||||
|
List<Double> longFeeRatesRange = new ArrayList<>();
|
||||||
|
longFeeRatesRange.add(minimumRelayFeeRate);
|
||||||
|
longFeeRatesRange.addAll(LONG_FEE_RATES_RANGE);
|
||||||
|
return longFeeRatesRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Double> getFeeRatesRange() {
|
||||||
|
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
|
return LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
|
||||||
|
} else {
|
||||||
|
List<Double> longFeeRatesRange = getLongFeeRatesRange();
|
||||||
|
return longFeeRatesRange.subList(0, longFeeRatesRange.size() - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static Double getNextBlockMedianFeeRate() {
|
public static Double getNextBlockMedianFeeRate() {
|
||||||
return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate;
|
return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate;
|
||||||
}
|
}
|
||||||
|
|
@ -788,10 +810,18 @@ public class AppServices {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Double getConfiguredMinimumRelayFeeRate(Config config) {
|
||||||
|
return config.getMinRelayFeeRate() >= 0d && config.getMinRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE ? config.getMinRelayFeeRate() : null;
|
||||||
|
}
|
||||||
|
|
||||||
public static Double getMinimumRelayFeeRate() {
|
public static Double getMinimumRelayFeeRate() {
|
||||||
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
|
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Double getServerMinimumRelayFeeRate() {
|
||||||
|
return serverMinimumRelayFeeRate;
|
||||||
|
}
|
||||||
|
|
||||||
public static CurrencyRate getFiatCurrencyExchangeRate() {
|
public static CurrencyRate getFiatCurrencyExchangeRate() {
|
||||||
return fiatCurrencyExchangeRate;
|
return fiatCurrencyExchangeRate;
|
||||||
}
|
}
|
||||||
|
|
@ -1219,7 +1249,10 @@ public class AppServices {
|
||||||
public void newConnection(ConnectionEvent event) {
|
public void newConnection(ConnectionEvent event) {
|
||||||
currentBlockHeight = event.getBlockHeight();
|
currentBlockHeight = event.getBlockHeight();
|
||||||
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
|
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
|
||||||
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
|
if(getConfiguredMinimumRelayFeeRate(Config.get()) == null) {
|
||||||
|
minimumRelayFeeRate = event.getMinimumRelayFeeRate() == null ? Transaction.DEFAULT_MIN_RELAY_FEE : event.getMinimumRelayFeeRate();
|
||||||
|
}
|
||||||
|
serverMinimumRelayFeeRate = event.getMinimumRelayFeeRate();
|
||||||
latestBlockHeader = event.getBlockHeader();
|
latestBlockHeader = event.getBlockHeader();
|
||||||
Config.get().addRecentServer();
|
Config.get().addRecentServer();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.sparrowwallet.sparrow.control;
|
package com.sparrowwallet.sparrow.control;
|
||||||
|
|
||||||
|
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||||
import com.sparrowwallet.sparrow.AppServices;
|
import com.sparrowwallet.sparrow.AppServices;
|
||||||
import com.sparrowwallet.sparrow.net.FeeRatesSource;
|
import com.sparrowwallet.sparrow.net.FeeRatesSource;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
|
|
@ -7,6 +8,7 @@ import javafx.scene.Node;
|
||||||
import javafx.scene.control.Slider;
|
import javafx.scene.control.Slider;
|
||||||
import javafx.util.StringConverter;
|
import javafx.util.StringConverter;
|
||||||
|
|
||||||
|
import java.text.DecimalFormat;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
|
@ -14,9 +16,11 @@ import static com.sparrowwallet.sparrow.AppServices.*;
|
||||||
|
|
||||||
public class FeeRangeSlider extends Slider {
|
public class FeeRangeSlider extends Slider {
|
||||||
private static final double FEE_RATE_SCROLL_INCREMENT = 0.01;
|
private static final double FEE_RATE_SCROLL_INCREMENT = 0.01;
|
||||||
|
private static final DecimalFormat INTEGER_FEE_RATE_FORMAT = new DecimalFormat("0");
|
||||||
|
private static final DecimalFormat FRACTIONAL_FEE_RATE_FORMAT = new DecimalFormat("0.###");
|
||||||
|
|
||||||
public FeeRangeSlider() {
|
public FeeRangeSlider() {
|
||||||
super(0, FEE_RATES_RANGE.size() - 1, 0);
|
super(0, AppServices.getFeeRatesRange().size() - 1, 0);
|
||||||
setMajorTickUnit(1);
|
setMajorTickUnit(1);
|
||||||
setMinorTickCount(0);
|
setMinorTickCount(0);
|
||||||
setSnapToTicks(false);
|
setSnapToTicks(false);
|
||||||
|
|
@ -27,11 +31,11 @@ public class FeeRangeSlider extends Slider {
|
||||||
setLabelFormatter(new StringConverter<>() {
|
setLabelFormatter(new StringConverter<>() {
|
||||||
@Override
|
@Override
|
||||||
public String toString(Double object) {
|
public String toString(Double object) {
|
||||||
Long feeRate = LONG_FEE_RATES_RANGE.get(object.intValue());
|
Double feeRate = AppServices.getLongFeeRatesRange().get(object.intValue());
|
||||||
if(isLongFeeRange() && feeRate >= 1000) {
|
if(isLongFeeRange() && feeRate >= 1000) {
|
||||||
return feeRate / 1000 + "k";
|
return INTEGER_FEE_RATE_FORMAT.format(feeRate / 1000) + "k";
|
||||||
}
|
}
|
||||||
return Long.toString(feeRate);
|
return feeRate > 0d && feeRate < Transaction.DEFAULT_MIN_RELAY_FEE ? FRACTIONAL_FEE_RATE_FORMAT.format(feeRate) : INTEGER_FEE_RATE_FORMAT.format(feeRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -51,10 +55,10 @@ public class FeeRangeSlider extends Slider {
|
||||||
setOnScroll(event -> {
|
setOnScroll(event -> {
|
||||||
if(event.getDeltaY() != 0) {
|
if(event.getDeltaY() != 0) {
|
||||||
double newFeeRate = getFeeRate() + (event.getDeltaY() > 0 ? FEE_RATE_SCROLL_INCREMENT : -FEE_RATE_SCROLL_INCREMENT);
|
double newFeeRate = getFeeRate() + (event.getDeltaY() > 0 ? FEE_RATE_SCROLL_INCREMENT : -FEE_RATE_SCROLL_INCREMENT);
|
||||||
if(newFeeRate < LONG_FEE_RATES_RANGE.get(0)) {
|
if(newFeeRate < AppServices.getLongFeeRatesRange().getFirst()) {
|
||||||
newFeeRate = LONG_FEE_RATES_RANGE.get(0);
|
newFeeRate = AppServices.getLongFeeRatesRange().getFirst();
|
||||||
} else if(newFeeRate > LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1)) {
|
} else if(newFeeRate > AppServices.getLongFeeRatesRange().getLast()) {
|
||||||
newFeeRate = LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1);
|
newFeeRate = AppServices.getLongFeeRatesRange().getLast();
|
||||||
}
|
}
|
||||||
setFeeRate(newFeeRate);
|
setFeeRate(newFeeRate);
|
||||||
}
|
}
|
||||||
|
|
@ -62,27 +66,79 @@ public class FeeRangeSlider extends Slider {
|
||||||
}
|
}
|
||||||
|
|
||||||
public double getFeeRate() {
|
public double getFeeRate() {
|
||||||
|
return getFeeRate(AppServices.getMinimumRelayFeeRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getFeeRate(Double minRelayFeeRate) {
|
||||||
|
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
return Math.pow(2.0, getValue());
|
return Math.pow(2.0, getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(getValue() < 1.0d) {
|
||||||
|
if(minRelayFeeRate == 0.0d) {
|
||||||
|
return getValue();
|
||||||
|
}
|
||||||
|
return Math.pow(minRelayFeeRate, 1.0d - getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.pow(2.0, getValue() - 1.0d);
|
||||||
|
}
|
||||||
|
|
||||||
public void setFeeRate(double feeRate) {
|
public void setFeeRate(double feeRate) {
|
||||||
double value = Math.log(feeRate) / Math.log(2);
|
setFeeRate(feeRate, AppServices.getMinimumRelayFeeRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFeeRate(double feeRate, Double minRelayFeeRate) {
|
||||||
|
double value = getValue(feeRate, minRelayFeeRate);
|
||||||
updateMaxFeeRange(value);
|
updateMaxFeeRange(value);
|
||||||
setValue(value);
|
setValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private double getValue(double feeRate, Double minRelayFeeRate) {
|
||||||
|
double value;
|
||||||
|
|
||||||
|
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
|
value = Math.log(feeRate) / Math.log(2);
|
||||||
|
} else {
|
||||||
|
if(feeRate < Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
|
if(minRelayFeeRate == 0.0d) {
|
||||||
|
return feeRate;
|
||||||
|
}
|
||||||
|
value = 1.0d - (Math.log(feeRate) / Math.log(minRelayFeeRate));
|
||||||
|
} else {
|
||||||
|
value = (Math.log(feeRate) / Math.log(2.0)) + 1.0d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateFeeRange(Double minRelayFeeRate, Double previousMinRelayFeeRate) {
|
||||||
|
if(minRelayFeeRate != null && previousMinRelayFeeRate != null) {
|
||||||
|
setFeeRate(getFeeRate(previousMinRelayFeeRate), minRelayFeeRate);
|
||||||
|
}
|
||||||
|
setMinorTickCount(1);
|
||||||
|
setMinorTickCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
private void updateMaxFeeRange(double value) {
|
private void updateMaxFeeRange(double value) {
|
||||||
if(value >= getMax() && !isLongFeeRange()) {
|
if(value >= getMax() && !isLongFeeRange()) {
|
||||||
setMax(LONG_FEE_RATES_RANGE.size() - 1);
|
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
|
setMin(1.0d);
|
||||||
|
}
|
||||||
|
setMax(AppServices.getLongFeeRatesRange().size() - 1);
|
||||||
updateTrackHighlight();
|
updateTrackHighlight();
|
||||||
} else if(value == getMin() && isLongFeeRange()) {
|
} else if(value == getMin() && isLongFeeRange()) {
|
||||||
setMax(FEE_RATES_RANGE.size() - 1);
|
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||||
|
setMin(0.0d);
|
||||||
|
}
|
||||||
|
setMax(AppServices.getFeeRatesRange().size() - 1);
|
||||||
updateTrackHighlight();
|
updateTrackHighlight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isLongFeeRange() {
|
public boolean isLongFeeRange() {
|
||||||
return getMax() > FEE_RATES_RANGE.size() - 1;
|
return getMax() > AppServices.getFeeRatesRange().size() - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateTrackHighlight() {
|
public void updateTrackHighlight() {
|
||||||
|
|
@ -137,9 +193,9 @@ public class FeeRangeSlider extends Slider {
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getPercentageOfFeeRange(Double feeRate) {
|
private int getPercentageOfFeeRange(Double feeRate) {
|
||||||
double index = Math.log(feeRate) / Math.log(2);
|
double index = getValue(feeRate, AppServices.getMinimumRelayFeeRate());
|
||||||
if(isLongFeeRange()) {
|
if(isLongFeeRange()) {
|
||||||
index *= ((double)FEE_RATES_RANGE.size() / (LONG_FEE_RATES_RANGE.size())) * 0.99;
|
index *= ((double)AppServices.getFeeRatesRange().size() / (AppServices.getLongFeeRatesRange().size())) * 0.99;
|
||||||
}
|
}
|
||||||
return (int)Math.round(index * 10.0);
|
return (int)Math.round(index * 10.0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,14 +398,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||||
|
|
||||||
double feeRate = feeRange.getFeeRate();
|
double feeRate = feeRange.getFeeRate();
|
||||||
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
|
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
|
||||||
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
|
if(feeRate == AppServices.getMinimumRelayFeeRate() && feeRate > 0d) {
|
||||||
fee++;
|
fee++;
|
||||||
}
|
}
|
||||||
|
|
||||||
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
|
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
|
||||||
if(total - fee <= dustThreshold) {
|
if(total - fee <= dustThreshold) {
|
||||||
feeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
|
feeRate = AppServices.getMinimumRelayFeeRate();
|
||||||
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + 1;
|
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + (feeRate > 0d ? 1 : 0);
|
||||||
|
|
||||||
if(total - fee <= dustThreshold) {
|
if(total - fee <= dustThreshold) {
|
||||||
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
|
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,6 @@ public class TransactionDiagram extends GridPane {
|
||||||
GridPane.setConstraints(outputsPane, 5, 0);
|
GridPane.setConstraints(outputsPane, 5, 0);
|
||||||
|
|
||||||
getChildren().clear();
|
getChildren().clear();
|
||||||
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
|
|
||||||
|
|
||||||
List<Payment> userPayments = getUserPayments();
|
List<Payment> userPayments = getUserPayments();
|
||||||
if(!isFinal() && userPayments.size() > 1) {
|
if(!isFinal() && userPayments.size() > 1) {
|
||||||
|
|
@ -234,6 +233,8 @@ public class TransactionDiagram extends GridPane {
|
||||||
getChildren().add(totalsPane);
|
getChildren().add(totalsPane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
|
||||||
|
|
||||||
if(contextMenu == null) {
|
if(contextMenu == null) {
|
||||||
contextMenu = new ContextMenu();
|
contextMenu = new ContextMenu();
|
||||||
MenuItem menuItem = new MenuItem("Save as Image...");
|
MenuItem menuItem = new MenuItem("Save as Image...");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.sparrowwallet.sparrow.event;
|
package com.sparrowwallet.sparrow.event;
|
||||||
|
|
||||||
import com.sparrowwallet.drongo.protocol.BlockHeader;
|
import com.sparrowwallet.drongo.protocol.BlockHeader;
|
||||||
|
import com.sparrowwallet.sparrow.AppServices;
|
||||||
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
import com.sparrowwallet.sparrow.net.MempoolRateSize;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -13,6 +14,7 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
|
||||||
private final int blockHeight;
|
private final int blockHeight;
|
||||||
private final BlockHeader blockHeader;
|
private final BlockHeader blockHeader;
|
||||||
private final Double minimumRelayFeeRate;
|
private final Double minimumRelayFeeRate;
|
||||||
|
private final Double previousMinimumRelayFeeRate;
|
||||||
|
|
||||||
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double minimumRelayFeeRate) {
|
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double minimumRelayFeeRate) {
|
||||||
super(targetBlockFeeRates, mempoolRateSizes);
|
super(targetBlockFeeRates, mempoolRateSizes);
|
||||||
|
|
@ -21,6 +23,7 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
|
||||||
this.blockHeight = blockHeight;
|
this.blockHeight = blockHeight;
|
||||||
this.blockHeader = blockHeader;
|
this.blockHeader = blockHeader;
|
||||||
this.minimumRelayFeeRate = minimumRelayFeeRate;
|
this.minimumRelayFeeRate = minimumRelayFeeRate;
|
||||||
|
this.previousMinimumRelayFeeRate = AppServices.getMinimumRelayFeeRate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> getServerVersion() {
|
public List<String> getServerVersion() {
|
||||||
|
|
@ -42,4 +45,8 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
|
||||||
public Double getMinimumRelayFeeRate() {
|
public Double getMinimumRelayFeeRate() {
|
||||||
return minimumRelayFeeRate;
|
return minimumRelayFeeRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Double getPreviousMinimumRelayFeeRate() {
|
||||||
|
return previousMinimumRelayFeeRate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.io;
|
||||||
|
|
||||||
import com.google.gson.*;
|
import com.google.gson.*;
|
||||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||||
|
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||||
import com.sparrowwallet.sparrow.UnitFormat;
|
import com.sparrowwallet.sparrow.UnitFormat;
|
||||||
import com.sparrowwallet.sparrow.Mode;
|
import com.sparrowwallet.sparrow.Mode;
|
||||||
import com.sparrowwallet.sparrow.Theme;
|
import com.sparrowwallet.sparrow.Theme;
|
||||||
|
|
@ -83,6 +84,7 @@ public class Config {
|
||||||
private int maxPageSize = DEFAULT_PAGE_SIZE;
|
private int maxPageSize = DEFAULT_PAGE_SIZE;
|
||||||
private boolean usePayNym;
|
private boolean usePayNym;
|
||||||
private boolean mempoolFullRbf;
|
private boolean mempoolFullRbf;
|
||||||
|
private double minRelayFeeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
|
||||||
private Double appWidth;
|
private Double appWidth;
|
||||||
private Double appHeight;
|
private Double appHeight;
|
||||||
|
|
||||||
|
|
@ -708,6 +710,14 @@ public class Config {
|
||||||
flush();
|
flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public double getMinRelayFeeRate() {
|
||||||
|
return minRelayFeeRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMinRelayFeeRate(double minRelayFeeRate) {
|
||||||
|
this.minRelayFeeRate = minRelayFeeRate;
|
||||||
|
}
|
||||||
|
|
||||||
public Double getAppWidth() {
|
public Double getAppWidth() {
|
||||||
return appWidth;
|
return appWidth;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -616,6 +616,7 @@ public class PayNymController {
|
||||||
List<byte[]> opReturns = List.of(blindedPaymentCode);
|
List<byte[]> opReturns = List.of(blindedPaymentCode);
|
||||||
Double feeRate = AppServices.getDefaultFeeRate();
|
Double feeRate = AppServices.getDefaultFeeRate();
|
||||||
Double minimumFeeRate = AppServices.getMinimumFeeRate();
|
Double minimumFeeRate = AppServices.getMinimumFeeRate();
|
||||||
|
Double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
|
||||||
boolean groupByAddress = Config.get().isGroupByAddress();
|
boolean groupByAddress = Config.get().isGroupByAddress();
|
||||||
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
||||||
|
|
||||||
|
|
@ -623,7 +624,7 @@ public class PayNymController {
|
||||||
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true, false));
|
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true, false));
|
||||||
List<TxoFilter> txoFilters = List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(wallet));
|
List<TxoFilter> txoFilters = List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(wallet));
|
||||||
|
|
||||||
return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs);
|
return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, minRelayFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<BlockTransaction, WalletNode> getNotificationTransaction(PaymentCode externalPaymentCode) {
|
private Map<BlockTransaction, WalletNode> getNotificationTransaction(PaymentCode externalPaymentCode) {
|
||||||
|
|
|
||||||
|
|
@ -1139,7 +1139,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||||
|
|
||||||
if(fee.getValue() > 0) {
|
if(fee.getValue() > 0) {
|
||||||
double feeRateAmt = fee.getValue() / headersForm.getTransaction().getVirtualSize();
|
double feeRateAmt = fee.getValue() / headersForm.getTransaction().getVirtualSize();
|
||||||
if(feeRateAmt > AppServices.LONG_FEE_RATES_RANGE.get(AppServices.LONG_FEE_RATES_RANGE.size() - 1)) {
|
if(feeRateAmt > AppServices.getLongFeeRatesRange().getLast()) {
|
||||||
Optional<ButtonType> optType = AppServices.showWarningDialog("Very high fee rate!",
|
Optional<ButtonType> optType = AppServices.showWarningDialog("Very high fee rate!",
|
||||||
"This transaction pays a very high fee rate of " + String.format("%.0f", feeRateAmt) + " sats/vB.\n\nBroadcast this transaction?", ButtonType.YES, ButtonType.NO);
|
"This transaction pays a very high fee rate of " + String.format("%.0f", feeRateAmt) + " sats/vB.\n\nBroadcast this transaction?", ButtonType.YES, ButtonType.NO);
|
||||||
if(optType.isPresent() && optType.get() == ButtonType.NO) {
|
if(optType.isPresent() && optType.get() == ButtonType.NO) {
|
||||||
|
|
@ -1225,9 +1225,17 @@ public class HeadersController extends TransactionFormController implements Init
|
||||||
|
|
||||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||||
if(failMessage.startsWith("min relay fee not met")) {
|
if(failMessage.startsWith("min relay fee not met")) {
|
||||||
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum " + format.getCurrencyFormat().format(AppServices.getMinimumRelayFeeRate()) + " sats/vB. " +
|
if(AppServices.getServerMinimumRelayFeeRate() != null && !AppServices.getServerMinimumRelayFeeRate().equals(AppServices.getMinimumRelayFeeRate())) {
|
||||||
|
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum configured relay fee rate for the server of " +
|
||||||
|
format.getCurrencyFormat().format(AppServices.getServerMinimumRelayFeeRate()) + " sats/vB.");
|
||||||
|
} else {
|
||||||
|
Double minRelayFeeRate = AppServices.getServerMinimumRelayFeeRate() != null ? AppServices.getServerMinimumRelayFeeRate() : AppServices.getMinimumRelayFeeRate();
|
||||||
|
AppServices.showErrorDialog("Error broadcasting transaction", "The fee rate for the signed transaction is below the minimum " + format.getCurrencyFormat().format(minRelayFeeRate) + " sats/vB. " +
|
||||||
"This usually happens because a keystore has created a signature that is larger than necessary.\n\n" +
|
"This usually happens because a keystore has created a signature that is larger than necessary.\n\n" +
|
||||||
"You can solve this by recreating the transaction with a slightly increased fee rate.");
|
"You can solve this by recreating the transaction with a slightly increased fee rate.");
|
||||||
|
}
|
||||||
|
} else if(failMessage.startsWith("dust")) {
|
||||||
|
AppServices.showErrorDialog("Error broadcasting transaction", "The server will not accept this transaction for broadcast due to its configured dust limit policy.");
|
||||||
} else if(failMessage.startsWith("bad-txns-inputs-missingorspent")) {
|
} else if(failMessage.startsWith("bad-txns-inputs-missingorspent")) {
|
||||||
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error indicating some or all of the UTXOs this transaction is spending are missing or have already been spent.");
|
AppServices.showErrorDialog("Error broadcasting transaction", "The server returned an error indicating some or all of the UTXOs this transaction is spending are missing or have already been spent.");
|
||||||
} else if(failMessage.contains("mempool min fee not met")) {
|
} else if(failMessage.contains("mempool min fee not met")) {
|
||||||
|
|
|
||||||
|
|
@ -484,7 +484,6 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||||
validationSupport.registerValidator(fee, Validator.combine(
|
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 Inputs", userFeeSet.get() && insufficientInputsProperty.get()),
|
||||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0),
|
|
||||||
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee Rate", isInsufficientFeeRate())
|
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee Rate", isInsufficientFeeRate())
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -606,10 +605,11 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
try {
|
try {
|
||||||
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
|
List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
|
||||||
updateOptimizationButtons(payments);
|
updateOptimizationButtons(payments);
|
||||||
if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
|
if(!userFeeSet.get() || getFeeValueSats() != null) {
|
||||||
Wallet wallet = getWalletForm().getWallet();
|
Wallet wallet = getWalletForm().getWallet();
|
||||||
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
|
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
|
||||||
double feeRate = getUserFeeRate();
|
double feeRate = getUserFeeRate();
|
||||||
|
double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
|
||||||
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
|
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
|
||||||
boolean groupByAddress = Config.get().isGroupByAddress();
|
boolean groupByAddress = Config.get().isGroupByAddress();
|
||||||
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
||||||
|
|
@ -617,7 +617,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
|
|
||||||
walletTransactionService = new WalletTransactionService(addressNodeMap, wallet, getUtxoSelectors(payments), getTxoFilters(),
|
walletTransactionService = new WalletTransactionService(addressNodeMap, wallet, getUtxoSelectors(payments), getTxoFilters(),
|
||||||
payments, opReturnsList, excludedChangeNodes,
|
payments, opReturnsList, excludedChangeNodes,
|
||||||
feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, replacedTransaction);
|
feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, replacedTransaction);
|
||||||
walletTransactionService.setOnSucceeded(event -> {
|
walletTransactionService.setOnSucceeded(event -> {
|
||||||
if(!walletTransactionService.isIgnoreResult()) {
|
if(!walletTransactionService.isIgnoreResult()) {
|
||||||
walletTransactionProperty.setValue(walletTransactionService.getValue());
|
walletTransactionProperty.setValue(walletTransactionService.getValue());
|
||||||
|
|
@ -688,6 +688,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
private final Set<WalletNode> excludedChangeNodes;
|
private final Set<WalletNode> excludedChangeNodes;
|
||||||
private final double feeRate;
|
private final double feeRate;
|
||||||
private final double longTermFeeRate;
|
private final double longTermFeeRate;
|
||||||
|
private final double minRelayFeeRate;
|
||||||
private final Long fee;
|
private final Long fee;
|
||||||
private final Integer currentBlockHeight;
|
private final Integer currentBlockHeight;
|
||||||
private final boolean groupByAddress;
|
private final boolean groupByAddress;
|
||||||
|
|
@ -698,7 +699,8 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
public WalletTransactionService(Map<Wallet, Map<Address, WalletNode>> addressNodeMap,
|
public WalletTransactionService(Map<Wallet, Map<Address, WalletNode>> addressNodeMap,
|
||||||
Wallet wallet, List<UtxoSelector> utxoSelectors, List<TxoFilter> txoFilters,
|
Wallet wallet, List<UtxoSelector> utxoSelectors, List<TxoFilter> txoFilters,
|
||||||
List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> excludedChangeNodes,
|
List<Payment> payments, List<byte[]> opReturns, Set<WalletNode> excludedChangeNodes,
|
||||||
double feeRate, double longTermFeeRate, Long fee, Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, BlockTransaction replacedTransaction) {
|
double feeRate, double longTermFeeRate, double minRelayFeeRate, Long fee,
|
||||||
|
Integer currentBlockHeight, boolean groupByAddress, boolean includeMempoolOutputs, BlockTransaction replacedTransaction) {
|
||||||
this.addressNodeMap = addressNodeMap;
|
this.addressNodeMap = addressNodeMap;
|
||||||
this.wallet = wallet;
|
this.wallet = wallet;
|
||||||
this.utxoSelectors = utxoSelectors;
|
this.utxoSelectors = utxoSelectors;
|
||||||
|
|
@ -708,6 +710,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
this.excludedChangeNodes = excludedChangeNodes;
|
this.excludedChangeNodes = excludedChangeNodes;
|
||||||
this.feeRate = feeRate;
|
this.feeRate = feeRate;
|
||||||
this.longTermFeeRate = longTermFeeRate;
|
this.longTermFeeRate = longTermFeeRate;
|
||||||
|
this.minRelayFeeRate = minRelayFeeRate;
|
||||||
this.fee = fee;
|
this.fee = fee;
|
||||||
this.currentBlockHeight = currentBlockHeight;
|
this.currentBlockHeight = currentBlockHeight;
|
||||||
this.groupByAddress = groupByAddress;
|
this.groupByAddress = groupByAddress;
|
||||||
|
|
@ -747,7 +750,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
private WalletTransaction getWalletTransaction() throws InsufficientFundsException {
|
private WalletTransaction getWalletTransaction() throws InsufficientFundsException {
|
||||||
updateMessage("Selecting UTXOs...");
|
updateMessage("Selecting UTXOs...");
|
||||||
WalletTransaction walletTransaction = wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, excludedChangeNodes,
|
WalletTransaction walletTransaction = wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, excludedChangeNodes,
|
||||||
feeRate, longTermFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs);
|
feeRate, longTermFeeRate, minRelayFeeRate, fee, currentBlockHeight, groupByAddress, includeMempoolOutputs);
|
||||||
updateMessage("Deriving keys...");
|
updateMessage("Deriving keys...");
|
||||||
walletTransaction.updateAddressNodeMap(addressNodeMap, walletTransaction.getWallet());
|
walletTransaction.updateAddressNodeMap(addressNodeMap, walletTransaction.getWallet());
|
||||||
return walletTransaction;
|
return walletTransaction;
|
||||||
|
|
@ -878,7 +881,7 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
* @return the fee rate to use when constructing a transaction
|
* @return the fee rate to use when constructing a transaction
|
||||||
*/
|
*/
|
||||||
public Double getUserFeeRate() {
|
public Double getUserFeeRate() {
|
||||||
return (userFeeSet.get() ? Transaction.DEFAULT_MIN_RELAY_FEE : getFeeRate());
|
return (userFeeSet.get() ? AppServices.getMinimumRelayFeeRate() : getFeeRate());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Double getFeeRate() {
|
public Double getFeeRate() {
|
||||||
|
|
@ -942,7 +945,6 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
|
|
||||||
private void setFeeRatePriority(Double feeRateAmt) {
|
private void setFeeRatePriority(Double feeRateAmt) {
|
||||||
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
||||||
Integer targetBlocks = getTargetBlocks(feeRateAmt);
|
|
||||||
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
|
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
|
||||||
Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
|
Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
|
||||||
if(minFeeRate > 1.0 && feeRateAmt < minFeeRate) {
|
if(minFeeRate > 1.0 && feeRateAmt < minFeeRate) {
|
||||||
|
|
@ -963,9 +965,10 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Integer targetBlocks = getTargetBlocks(feeRateAmt);
|
||||||
if(targetBlocks != null) {
|
if(targetBlocks != null) {
|
||||||
if(targetBlocks < FeeRatesSource.BLOCKS_IN_HALF_HOUR) {
|
if(targetBlocks < FeeRatesSource.BLOCKS_IN_HALF_HOUR) {
|
||||||
Double maxFeeRate = FEE_RATES_RANGE.get(FEE_RATES_RANGE.size() - 1).doubleValue();
|
Double maxFeeRate = AppServices.getFeeRatesRange().getLast();
|
||||||
Double highestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(0));
|
Double highestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(0));
|
||||||
if(highestBlocksRate < maxFeeRate && feeRateAmt > (highestBlocksRate + ((maxFeeRate - highestBlocksRate) / 10))) {
|
if(highestBlocksRate < maxFeeRate && feeRateAmt > (highestBlocksRate + ((maxFeeRate - highestBlocksRate) / 10))) {
|
||||||
feeRatePriority.setText("Overpaid");
|
feeRatePriority.setText("Overpaid");
|
||||||
|
|
@ -1243,11 +1246,13 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
List<UtxoSelector> utxoSelectors = List.of(new PresetUtxoSelector(walletTransaction.getSelectedUtxos().keySet(), true, false));
|
List<UtxoSelector> utxoSelectors = List.of(new PresetUtxoSelector(walletTransaction.getSelectedUtxos().keySet(), true, false));
|
||||||
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
|
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
|
||||||
double feeRate = getUserFeeRate();
|
double feeRate = getUserFeeRate();
|
||||||
|
Double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
|
||||||
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
|
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
|
||||||
boolean groupByAddress = Config.get().isGroupByAddress();
|
boolean groupByAddress = Config.get().isGroupByAddress();
|
||||||
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
||||||
|
|
||||||
WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(utxoSelectors, getTxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode), excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs);
|
WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(utxoSelectors, getTxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode),
|
||||||
|
excludedChangeNodes, feeRate, getMinimumFeeRate(), minRelayFeeRate, userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs);
|
||||||
PSBT psbt = finalWalletTx.createPSBT();
|
PSBT psbt = finalWalletTx.createPSBT();
|
||||||
decryptedWallet.sign(psbt);
|
decryptedWallet.sign(psbt);
|
||||||
decryptedWallet.finalise(psbt);
|
decryptedWallet.finalise(psbt);
|
||||||
|
|
@ -1635,6 +1640,14 @@ public class SendController extends WalletFormController implements Initializabl
|
||||||
recentBlocksView.updateFeeRatesSource(event.getFeeRateSource());
|
recentBlocksView.updateFeeRatesSource(event.getFeeRateSource());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void connection(ConnectionEvent event) {
|
||||||
|
if(!Objects.equals(event.getMinimumRelayFeeRate(), event.getPreviousMinimumRelayFeeRate())) {
|
||||||
|
feeRange.updateFeeRange(event.getMinimumRelayFeeRate(), event.getPreviousMinimumRelayFeeRate());
|
||||||
|
updateTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class PrivacyAnalysisTooltip extends VBox {
|
private class PrivacyAnalysisTooltip extends VBox {
|
||||||
private final List<Label> analysisLabels = new ArrayList<>();
|
private final List<Label> analysisLabels = new ArrayList<>();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue