add recent blocks view

This commit is contained in:
Craig Raw 2025-05-14 08:19:21 +02:00
parent e697313259
commit 94b27ba7e8
11 changed files with 696 additions and 40 deletions

View file

@ -305,12 +305,6 @@ public class AppServices {
if(event != null) { if(event != null) {
EventManager.get().post(event); EventManager.get().post(event);
} }
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(event instanceof ConnectionEvent && feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
EventManager.get().post(new FeeRatesSourceChangedEvent(feeRatesSource));
}
}); });
connectionService.setOnFailed(failEvent -> { connectionService.setOnFailed(failEvent -> {
//Close connection here to create a new transport next time we try //Close connection here to create a new transport next time we try
@ -494,6 +488,13 @@ public class AppServices {
} }
} }
private void fetchFeeRates() {
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
}
private void fetchBlockSummaries(List<NewBlockEvent> newBlockEvents) { private void fetchBlockSummaries(List<NewBlockEvent> newBlockEvents) {
if(isConnected()) { if(isConnected()) {
ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents); ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents);
@ -1216,6 +1217,12 @@ public class AppServices {
latestBlockHeader = event.getBlockHeader(); latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer(); Config.get().addRecentServer();
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get()) && feeRatesSource.isExternal()) {
fetchFeeRates();
}
if(!blockSummaries.containsKey(currentBlockHeight)) { if(!blockSummaries.containsKey(currentBlockHeight)) {
fetchBlockSummaries(Collections.emptyList()); fetchBlockSummaries(Collections.emptyList());
} }
@ -1259,10 +1266,8 @@ public class AppServices {
@Subscribe @Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) { public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
//Perform once-off fee rates retrieval to immediately change displayed rates //Perform once-off fee rates retrieval to immediately change displayed rates
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) { fetchFeeRates();
feeRatesService = createFeeRatesService(); fetchBlockSummaries(Collections.emptyList());
feeRatesService.start();
}
} }
@Subscribe @Subscribe

View file

@ -5,21 +5,23 @@ import java.time.temporal.ChronoUnit;
import java.util.Date; import java.util.Date;
import java.util.Optional; import java.util.Optional;
public class BlockSummary { public class BlockSummary implements Comparable<BlockSummary> {
private final Integer height; private final Integer height;
private final Date timestamp; private final Date timestamp;
private final Double medianFee; private final Double medianFee;
private final Integer transactionCount; private final Integer transactionCount;
private final Integer weight;
public BlockSummary(Integer height, Date timestamp) { public BlockSummary(Integer height, Date timestamp) {
this(height, timestamp, null, null); this(height, timestamp, null, null, null);
} }
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount) { public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount, Integer weight) {
this.height = height; this.height = height;
this.timestamp = timestamp; this.timestamp = timestamp;
this.medianFee = medianFee; this.medianFee = medianFee;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.weight = weight;
} }
public Integer getHeight() { public Integer getHeight() {
@ -38,6 +40,10 @@ public class BlockSummary {
return transactionCount == null ? Optional.empty() : Optional.of(transactionCount); return transactionCount == null ? Optional.empty() : Optional.of(transactionCount);
} }
public Optional<Integer> getWeight() {
return weight == null ? Optional.empty() : Optional.of(weight);
}
private static long calculateElapsedSeconds(long timestampUtc) { private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc); Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now(); Instant nowInstant = Instant.now();
@ -62,4 +68,9 @@ public class BlockSummary {
public String toString() { public String toString() {
return getElapsed() + ":" + getMedianFee(); return getElapsed() + ":" + getMedianFee();
} }
@Override
public int compareTo(BlockSummary o) {
return o.height - height;
}
} }

View file

@ -0,0 +1,323 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.BlockSummary;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.scene.Group;
import javafx.scene.shape.Polygon;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.FontWeight;
import javafx.scene.text.Text;
import javafx.util.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
public class BlockCube extends Group {
public static final List<Integer> MEMPOOL_FEE_RATES_INTERVALS = List.of(1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 175, 200, 250, 300, 350, 400, 500, 600, 700, 800, 900, 1000, 1200, 1400, 1600, 1800, 2000);
public static final double CUBE_SIZE = 60;
private final IntegerProperty weightProperty = new SimpleIntegerProperty(0);
private final DoubleProperty medianFeeProperty = new SimpleDoubleProperty(0);
private final IntegerProperty heightProperty = new SimpleIntegerProperty(0);
private final IntegerProperty txCountProperty = new SimpleIntegerProperty(0);
private final LongProperty timestampProperty = new SimpleLongProperty(System.currentTimeMillis());
private final StringProperty elapsedProperty = new SimpleStringProperty("");
private final BooleanProperty confirmedProperty = new SimpleBooleanProperty(false);
private Polygon front;
private Rectangle unusedArea;
private Rectangle usedArea;
private final Text heightText = new Text();
private final Text medianFeeText = new Text();
private final Text txCountText = new Text();
private final Text elapsedText = new Text();
public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) {
getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube");
this.confirmedProperty.set(confirmed);
this.weightProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeProperty.addListener((_, _, newValue) -> {
medianFeeText.setText("~" + Math.round(Math.max(newValue.doubleValue(), 1.0d)) + " s/vb");
medianFeeText.setX((CUBE_SIZE - medianFeeText.getLayoutBounds().getWidth()) / 2);
});
this.txCountProperty.addListener((_, _, newValue) -> {
txCountText.setText(newValue.intValue() == 0 ? "" : newValue + " txes");
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
});
this.timestampProperty.addListener((_, _, newValue) -> {
elapsedProperty.set(getElapsed(newValue.longValue()));
});
this.elapsedProperty.addListener((_, _, newValue) -> {
elapsedText.setText(isConfirmed() ? newValue : "In ~10m");
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
});
this.heightProperty.addListener((_, _, newValue) -> {
heightText.setText(newValue.intValue() == 0 ? "" : String.valueOf(newValue));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
});
this.confirmedProperty.addListener((_, _, _) -> {
if(front != null) {
updateFill();
}
});
this.medianFeeText.textProperty().addListener((_, _, _) -> {
pulse();
});
if(weight != null) {
this.weightProperty.set(weight);
}
if(medianFee != null) {
this.medianFeeProperty.set(medianFee);
}
if(height != null) {
this.heightProperty.set(height);
}
if(txCount != null) {
this.txCountProperty.set(txCount);
}
if(timestamp != null) {
this.timestampProperty.set(timestamp);
}
drawCube();
}
private void drawCube() {
double depth = CUBE_SIZE * 0.2;
double perspective = CUBE_SIZE * 0.04;
front = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE, CUBE_SIZE, 0, CUBE_SIZE);
front.getStyleClass().add("block-front");
front.setFill(null);
unusedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
unusedArea.getStyleClass().add("block-unused");
usedArea = new Rectangle(0, 0, CUBE_SIZE, CUBE_SIZE);
usedArea.getStyleClass().add("block-used");
Group frontFaceGroup = new Group(front, unusedArea, usedArea);
Polygon top = new Polygon(0, 0, CUBE_SIZE, 0, CUBE_SIZE - depth - perspective, -depth, -depth, -depth);
top.getStyleClass().add("block-top");
top.setStroke(null);
Polygon left = new Polygon(0, 0, -depth, -depth, -depth, CUBE_SIZE - depth - perspective, 0, CUBE_SIZE);
left.getStyleClass().add("block-left");
left.setStroke(null);
updateFill();
heightText.getStyleClass().add("block-height");
heightText.setFont(new Font(11));
heightText.setX(((CUBE_SIZE * 0.7) - heightText.getLayoutBounds().getWidth()) / 2);
heightText.setY(-24);
medianFeeText.getStyleClass().add("block-text");
medianFeeText.setFont(Font.font(null, FontWeight.BOLD, 11));
medianFeeText.setX((CUBE_SIZE - medianFeeText.getLayoutBounds().getWidth()) / 2);
medianFeeText.setY(16);
txCountText.getStyleClass().add("block-text");
txCountText.setFont(new Font(10));
txCountText.setOpacity(0.7);
txCountText.setX((CUBE_SIZE - txCountText.getLayoutBounds().getWidth()) / 2);
txCountText.setY(34);
elapsedText.getStyleClass().add("block-text");
elapsedText.setFont(new Font(10));
elapsedText.setX((CUBE_SIZE - elapsedText.getLayoutBounds().getWidth()) / 2);
elapsedText.setY(50);
getChildren().addAll(frontFaceGroup, top, left, heightText, medianFeeText, txCountText, elapsedText);
}
private void updateFill() {
if(isConfirmed()) {
getStyleClass().removeAll("block-unconfirmed");
if(!getStyleClass().contains("block-confirmed")) {
getStyleClass().add("block-confirmed");
}
double startY = 1 - weightProperty.doubleValue() / (Transaction.MAX_BLOCK_SIZE_VBYTES * Transaction.WITNESS_SCALE_FACTOR);
double startYAbsolute = startY * BlockCube.CUBE_SIZE;
unusedArea.setHeight(startYAbsolute);
unusedArea.setStyle(null);
usedArea.setY(startYAbsolute);
usedArea.setHeight(CUBE_SIZE - startYAbsolute);
usedArea.setVisible(true);
heightText.setVisible(true);
} else {
getStyleClass().removeAll("block-confirmed");
if(!getStyleClass().contains("block-unconfirmed")) {
getStyleClass().add("block-unconfirmed");
}
usedArea.setVisible(false);
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
heightText.setVisible(false);
}
}
public void pulse() {
if(isConfirmed()) {
return;
}
if(unusedArea != null) {
unusedArea.setStyle("-fx-fill: " + getFeeRateStyleName() + ";");
}
Timeline timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(opacityProperty(), 1.0)),
new KeyFrame(Duration.millis(500), new KeyValue(opacityProperty(), 0.7)),
new KeyFrame(Duration.millis(1000), new KeyValue(opacityProperty(), 1.0))
);
timeline.setCycleCount(1);
timeline.play();
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public static String getElapsed(long timestampUtc) {
long elapsed = calculateElapsedSeconds(timestampUtc);
if(elapsed < 60) {
return "Just now";
} else if(elapsed < 3600) {
return Math.round(elapsed / 60f) + "m ago";
} else if(elapsed < 86400) {
return Math.round(elapsed / 3600f) + "h ago";
} else {
return Math.round(elapsed / 86400d) + "d ago";
}
}
private String getFeeRateStyleName() {
double rate = getMedianFee();
int[] feeRateInterval = getFeeRateInterval(rate);
if(feeRateInterval[1] == Integer.MAX_VALUE) {
return "VSIZE2000-2200_COLOR";
}
int[] nextRateInterval = getFeeRateInterval(rate * 2);
String from = "VSIZE" + feeRateInterval[0] + "-" + feeRateInterval[1] + "_COLOR";
String to = "VSIZE" + nextRateInterval[0] + "-" + (nextRateInterval[1] == Integer.MAX_VALUE ? "2200" : nextRateInterval[1]) + "_COLOR";
return "linear-gradient(from 75% 0% to 100% 0%, " + from + " 0%, " + to + " 100%, " + from +")";
}
private int[] getFeeRateInterval(double medianFee) {
for(int i = 0; i < MEMPOOL_FEE_RATES_INTERVALS.size(); i++) {
int feeRate = MEMPOOL_FEE_RATES_INTERVALS.get(i);
int nextFeeRate = (i == MEMPOOL_FEE_RATES_INTERVALS.size() - 1 ? Integer.MAX_VALUE : MEMPOOL_FEE_RATES_INTERVALS.get(i + 1));
if(feeRate <= medianFee && nextFeeRate > medianFee) {
return new int[] { feeRate, nextFeeRate };
}
}
return new int[] { 1, 2 };
}
public int getWeight() {
return weightProperty.get();
}
public IntegerProperty weightProperty() {
return weightProperty;
}
public void setWeight(int weight) {
weightProperty.set(weight);
}
public double getMedianFee() {
return medianFeeProperty.get();
}
public DoubleProperty medianFee() {
return medianFeeProperty;
}
public void setMedianFee(double medianFee) {
medianFeeProperty.set(medianFee);
}
public int getHeight() {
return heightProperty.get();
}
public IntegerProperty heightProperty() {
return heightProperty;
}
public void setHeight(int height) {
heightProperty.set(height);
}
public int getTxCount() {
return txCountProperty.get();
}
public IntegerProperty txCountProperty() {
return txCountProperty;
}
public void setTxCount(int txCount) {
txCountProperty.set(txCount);
}
public long getTimestamp() {
return timestampProperty.get();
}
public LongProperty timestampProperty() {
return timestampProperty;
}
public void setTimestamp(long timestamp) {
timestampProperty.set(timestamp);
}
public String getElapsed() {
return elapsedProperty.get();
}
public StringProperty elapsedProperty() {
return elapsedProperty;
}
public void setElapsed(String elapsed) {
elapsedProperty.set(elapsed);
}
public boolean isConfirmed() {
return confirmedProperty.get();
}
public BooleanProperty confirmedProperty() {
return confirmedProperty;
}
public void setConfirmed(boolean confirmed) {
confirmedProperty.set(confirmed);
}
public static BlockCube fromBlockSummary(BlockSummary blockSummary) {
return new BlockCube(blockSummary.getWeight().orElse(0), blockSummary.getMedianFee().orElse(0.0d), blockSummary.getHeight(),
blockSummary.getTransactionCount().orElse(0), blockSummary.getTimestamp().getTime(), true);
}
}

View file

@ -0,0 +1,150 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.BlockSummary;
import io.reactivex.Observable;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import javafx.animation.TranslateTransition;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.Pane;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class RecentBlocksView extends Pane {
private static final double CUBE_SPACING = 100;
private static final double ANIMATION_DURATION_MILLIS = 1000;
private static final double SEPARATOR_X = 74;
private final CompositeDisposable disposables = new CompositeDisposable();
private final ObjectProperty<List<BlockCube>> cubesProperty = new SimpleObjectProperty<>(new ArrayList<>());
public RecentBlocksView() {
cubesProperty.addListener((_, _, newValue) -> {
if(newValue != null && newValue.size() == 3) {
drawView();
}
});
Rectangle clip = new Rectangle(-20, -40, CUBE_SPACING * 3 - 20, 100);
setClip(clip);
Observable<Long> intervalObservable = Observable.interval(1, TimeUnit.MINUTES);
disposables.add(intervalObservable.observeOn(JavaFxScheduler.platform()).subscribe(_ -> {
for(BlockCube cube : getCubes()) {
cube.setElapsed(BlockCube.getElapsed(cube.getTimestamp()));
}
}));
}
public void drawView() {
createSeparator();
for(int i = 0; i < 3; i++) {
BlockCube cube = getCubes().get(i);
cube.setTranslateX(i * CUBE_SPACING);
getChildren().add(cube);
}
}
private void createSeparator() {
Line separator = new Line(SEPARATOR_X, -9, SEPARATOR_X, 80);
separator.getStyleClass().add("blocks-separator");
separator.getStrokeDashArray().addAll(5.0, 5.0); // Create dotted line pattern
separator.setStrokeWidth(1.0);
getChildren().add(separator);
}
public void update(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
List<BlockCube> cubes = new ArrayList<>();
cubes.add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
cubes.addAll(latestBlocks.stream().map(BlockCube::fromBlockSummary).limit(2).toList());
setCubes(cubes);
} else {
int knownTip = getCubes().stream().mapToInt(BlockCube::getHeight).max().orElse(0);
int latestTip = latestBlocks.stream().mapToInt(BlockSummary::getHeight).max().orElse(0);
if(latestTip > knownTip) {
addNewBlock(latestBlocks, currentFeeRate);
} else {
for(int i = 1; i < getCubes().size() && i < latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(0.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
updateFeeRate(currentFeeRate);
}
}
}
public void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
return;
}
for(int i = 0; i < getCubes().size() && i < latestBlocks.size(); i++) {
BlockCube blockCube = getCubes().get(i);
BlockSummary latestBlock = latestBlocks.get(i);
blockCube.setConfirmed(true);
blockCube.setHeight(latestBlock.getHeight());
blockCube.setTimestamp(latestBlock.getTimestamp().getTime());
blockCube.setWeight(latestBlock.getWeight().orElse(0));
blockCube.setMedianFee(latestBlock.getMedianFee().orElse(0.0d));
blockCube.setTxCount(latestBlock.getTransactionCount().orElse(0));
}
add(new BlockCube(null, currentFeeRate, null, null, 0L, false));
}
public void add(BlockCube newCube) {
newCube.setTranslateX(-CUBE_SPACING);
getChildren().add(newCube);
getCubes().getFirst().setConfirmed(true);
getCubes().addFirst(newCube);
animateCubes();
if(getCubes().size() > 4) {
BlockCube lastCube = getCubes().getLast();
getChildren().remove(lastCube);
getCubes().remove(lastCube);
}
}
public void updateFeeRate(Double currentFeeRate) {
if(!getCubes().isEmpty()) {
BlockCube firstCube = getCubes().getFirst();
firstCube.setMedianFee(currentFeeRate);
}
}
private void animateCubes() {
for(int i = 0; i < getCubes().size(); i++) {
BlockCube cube = getCubes().get(i);
TranslateTransition transition = new TranslateTransition(Duration.millis(ANIMATION_DURATION_MILLIS), cube);
transition.setToX(i * CUBE_SPACING);
transition.play();
}
}
public List<BlockCube> getCubes() {
return cubesProperty.get();
}
public ObjectProperty<List<BlockCube>> cubesProperty() {
return cubesProperty;
}
public void setCubes(List<BlockCube> cubes) {
this.cubesProperty.set(cubes);
}
}

View file

@ -1945,13 +1945,18 @@ public class ElectrumServer {
if(startHeight == 0 || totalBlocks > 1 || startHeight > maxHeight + 1) { if(startHeight == 0 || totalBlocks > 1 || startHeight > maxHeight + 1) {
if(isBlockstorm(totalBlocks)) { if(isBlockstorm(totalBlocks)) {
for(int height = maxHeight + 1; height < endHeight; height++) { int start = Math.max(maxHeight + 1, endHeight - 15);
blockSummaryMap.put(height, new BlockSummary(height, new Date())); for(int height = start; height <= endHeight; height++) {
blockSummaryMap.put(height, new BlockSummary(height, new Date(), 1.0d, 0, 0));
} }
} else { } else {
blockSummaryMap.putAll(electrumServer.getRecentBlockSummaryMap()); blockSummaryMap.putAll(electrumServer.getRecentBlockSummaryMap());
} }
} else { }
List<NewBlockEvent> events = new ArrayList<>(newBlockEvents);
events.removeIf(event -> blockSummaryMap.containsKey(event.getHeight()));
if(!events.isEmpty()) {
for(NewBlockEvent event : newBlockEvents) { for(NewBlockEvent event : newBlockEvents) {
blockSummaryMap.putAll(electrumServer.getBlockSummaryMap(event.getHeight(), event.getBlockHeader())); blockSummaryMap.putAll(electrumServer.getBlockSummaryMap(event.getHeight(), event.getBlockHeader()));
} }

View file

@ -285,7 +285,7 @@ public enum FeeRatesSource {
} }
} }
protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, MempoolBlockSummaryExtras extras) { protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, Integer weight, MempoolBlockSummaryExtras extras) {
public Double getMedianFee() { public Double getMedianFee() {
return extras == null ? null : extras.medianFee(); return extras == null ? null : extras.medianFee();
} }
@ -294,7 +294,7 @@ public enum FeeRatesSource {
if(height == null || timestamp == null) { if(height == null || timestamp == null) {
throw new IllegalStateException("Height = " + height + ", timestamp = " + timestamp + ": both must be specified"); throw new IllegalStateException("Height = " + height + ", timestamp = " + timestamp + ": both must be specified");
} }
return new BlockSummary(height, new Date(timestamp * 1000), getMedianFee(), tx_count); return new BlockSummary(height, new Date(timestamp * 1000), getMedianFee(), tx_count, weight);
} }
} }

View file

@ -1,7 +1,7 @@
package com.sparrowwallet.sparrow.wallet; package com.sparrowwallet.sparrow.wallet;
public enum FeeRatesSelection { public enum FeeRatesSelection {
BLOCK_TARGET("Block Target"), MEMPOOL_SIZE("Mempool Size"); BLOCK_TARGET("Block Target"), MEMPOOL_SIZE("Mempool Size"), RECENT_BLOCKS("Recent Blocks");
private final String name; private final String name;

View file

@ -13,10 +13,7 @@ import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.UnitFormat; import com.sparrowwallet.sparrow.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@ -28,6 +25,7 @@ import com.sparrowwallet.sparrow.paynym.PayNymService;
import javafx.animation.KeyFrame; import javafx.animation.KeyFrame;
import javafx.animation.Timeline; import javafx.animation.Timeline;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*; import javafx.beans.property.*;
import javafx.beans.value.ChangeListener; import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue; import javafx.beans.value.ObservableValue;
@ -78,6 +76,9 @@ public class SendController extends WalletFormController implements Initializabl
@FXML @FXML
private ToggleButton mempoolSizeToggle; private ToggleButton mempoolSizeToggle;
@FXML
private ToggleButton recentBlocksToggle;
@FXML @FXML
private Field targetBlocksField; private Field targetBlocksField;
@ -117,6 +118,9 @@ public class SendController extends WalletFormController implements Initializabl
@FXML @FXML
private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart; private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart;
@FXML
private RecentBlocksView recentBlocksView;
@FXML @FXML
private TransactionDiagram transactionDiagram; private TransactionDiagram transactionDiagram;
@ -162,6 +166,8 @@ public class SendController extends WalletFormController implements Initializabl
private final ObjectProperty<BlockTransaction> replacedTransactionProperty = new SimpleObjectProperty<>(null); private final ObjectProperty<BlockTransaction> replacedTransactionProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<FeeRatesSelection> feeRatesSelectionProperty = new SimpleObjectProperty<>(null);
private final List<byte[]> opReturnsList = new ArrayList<>(); private final List<byte[]> opReturnsList = new ArrayList<>();
private final Set<WalletNode> excludedChangeNodes = new HashSet<>(); private final Set<WalletNode> excludedChangeNodes = new HashSet<>();
@ -299,6 +305,7 @@ public class SendController extends WalletFormController implements Initializabl
feeRange.valueProperty().addListener(feeRangeListener); feeRange.valueProperty().addListener(feeRangeListener);
blockTargetFeeRatesChart.managedProperty().bind(blockTargetFeeRatesChart.visibleProperty()); blockTargetFeeRatesChart.managedProperty().bind(blockTargetFeeRatesChart.visibleProperty());
blockTargetFeeRatesChart.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.BLOCK_TARGET));
blockTargetFeeRatesChart.initialize(); blockTargetFeeRatesChart.initialize();
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates(); Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
if(targetBlocksFeeRates != null) { if(targetBlocksFeeRates != null) {
@ -308,20 +315,41 @@ public class SendController extends WalletFormController implements Initializabl
} }
mempoolSizeFeeRatesChart.managedProperty().bind(mempoolSizeFeeRatesChart.visibleProperty()); mempoolSizeFeeRatesChart.managedProperty().bind(mempoolSizeFeeRatesChart.visibleProperty());
mempoolSizeFeeRatesChart.visibleProperty().bind(blockTargetFeeRatesChart.visibleProperty().not()); mempoolSizeFeeRatesChart.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.MEMPOOL_SIZE));
mempoolSizeFeeRatesChart.initialize(); mempoolSizeFeeRatesChart.initialize();
Map<Date, Set<MempoolRateSize>> mempoolHistogram = getMempoolHistogram(); Map<Date, Set<MempoolRateSize>> mempoolHistogram = getMempoolHistogram();
if(mempoolHistogram != null) { if(mempoolHistogram != null) {
mempoolSizeFeeRatesChart.update(mempoolHistogram); mempoolSizeFeeRatesChart.update(mempoolHistogram);
} }
recentBlocksView.managedProperty().bind(recentBlocksView.visibleProperty());
recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS));
List<BlockSummary> blockSummaries = AppServices.getBlockSummaries().values().stream().sorted().toList();
if(!blockSummaries.isEmpty()) {
recentBlocksView.update(blockSummaries, AppServices.getDefaultFeeRate());
}
feeRatesSelectionProperty.addListener((_, oldValue, newValue) -> {
boolean isBlockTargetSelection = (newValue == FeeRatesSelection.BLOCK_TARGET);
boolean wasBlockTargetSelection = (oldValue == FeeRatesSelection.BLOCK_TARGET || oldValue == null);
targetBlocksField.setVisible(isBlockTargetSelection);
if(isBlockTargetSelection) {
setTargetBlocks(getTargetBlocks(getFeeRangeRate()));
updateTransaction();
} else if(wasBlockTargetSelection) {
setFeeRangeRate(getTargetBlocksFeeRates().get(getTargetBlocks()));
updateTransaction();
}
});
FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection(); FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection();
feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.MEMPOOL_SIZE : feeRatesSelection); feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.RECENT_BLOCKS : feeRatesSelection);
cpfpFeeRate.managedProperty().bind(cpfpFeeRate.visibleProperty()); cpfpFeeRate.managedProperty().bind(cpfpFeeRate.visibleProperty());
cpfpFeeRate.setVisible(false); cpfpFeeRate.setVisible(false);
setDefaultFeeRate(); setDefaultFeeRate();
updateFeeRateSelection(feeRatesSelection); feeRatesSelectionProperty.set(feeRatesSelection);
feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle); feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle :
(feeRatesSelection == FeeRatesSelection.MEMPOOL_SIZE ? mempoolSizeToggle : recentBlocksToggle));
feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> { feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) { if(newValue != null) {
FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData(); FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
@ -723,24 +751,13 @@ public class SendController extends WalletFormController implements Initializabl
return List.of(spentTxoFilter, new FrozenTxoFilter(), new CoinbaseTxoFilter(getWalletForm().getWallet())); return List.of(spentTxoFilter, new FrozenTxoFilter(), new CoinbaseTxoFilter(getWalletForm().getWallet()));
} }
private void updateFeeRateSelection(FeeRatesSelection feeRatesSelection) {
boolean blockTargetSelection = (feeRatesSelection == FeeRatesSelection.BLOCK_TARGET);
targetBlocksField.setVisible(blockTargetSelection);
blockTargetFeeRatesChart.setVisible(blockTargetSelection);
if(blockTargetSelection) {
setTargetBlocks(getTargetBlocks(getFeeRangeRate()));
} else {
setFeeRangeRate(getTargetBlocksFeeRates().get(getTargetBlocks()));
}
updateTransaction();
}
private void setDefaultFeeRate() { private void setDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget); int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget);
Double defaultRate = getTargetBlocksFeeRates().get(defaultTarget); Double defaultRate = getTargetBlocksFeeRates().get(defaultTarget);
targetBlocks.setValue(index); targetBlocks.setValue(index);
blockTargetFeeRatesChart.select(defaultTarget); blockTargetFeeRatesChart.select(defaultTarget);
recentBlocksView.updateFeeRate(defaultRate);
setFeeRangeRate(defaultRate); setFeeRangeRate(defaultRate);
setFeeRate(getFeeRangeRate()); setFeeRate(getFeeRangeRate());
if(Network.get().equals(Network.MAINNET) && defaultRate == getFallbackFeeRate()) { if(Network.get().equals(Network.MAINNET) && defaultRate == getFallbackFeeRate()) {
@ -1411,10 +1428,15 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe @Subscribe
public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) { public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
if(event.getWallet() == getWalletForm().getWallet()) { if(event.getWallet() == getWalletForm().getWallet()) {
updateFeeRateSelection(event.getFeeRateSelection()); feeRatesSelectionProperty.set(event.getFeeRateSelection());
} }
} }
@Subscribe
public void blockSummary(BlockSummaryEvent event) {
Platform.runLater(() -> recentBlocksView.update(AppServices.getBlockSummaries().values().stream().sorted().toList(), AppServices.getDefaultFeeRate()));
}
@Subscribe @Subscribe
public void spendUtxos(SpendUtxoEvent event) { public void spendUtxos(SpendUtxoEvent event) {
if((event.getUtxos() == null || !event.getUtxos().isEmpty()) && event.getWallet().equals(getWalletForm().getWallet())) { if((event.getUtxos() == null || !event.getUtxos().isEmpty()) && event.getWallet().equals(getWalletForm().getWallet())) {

View file

@ -343,4 +343,32 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{
#grid .spreadsheet-cell.selection { #grid .spreadsheet-cell.selection {
-fx-text-fill: -fx-base; -fx-text-fill: -fx-base;
}
.root .block-height {
-fx-fill: derive(lightgray, -20%);
}
.root .blocks-separator {
-fx-stroke: derive(lightgray, -20%);
}
.root .block-confirmed .block-unused {
-fx-fill: #5a5a65;
}
.root .block-confirmed .block-top {
-fx-fill: #474c5e;
}
.root .block-confirmed .block-left {
-fx-fill: #3c4055;
}
.root .block-unconfirmed .block-top {
-fx-fill: #635b57;
}
.root .block-unconfirmed .block-left {
-fx-fill: #4e4846;
} }

View file

@ -132,6 +132,66 @@
-fx-text-fill: -fx-accent; -fx-text-fill: -fx-accent;
} }
.block-used {
-fx-fill: linear-gradient(from 0% 0% to 0% 100%, -top 0%, -bottom 100%);
}
.block-mainnet {
-top: rgb(155, 79, 174);
-bottom: rgb(77, 96, 154);
}
.block-testnet {
-top: rgb(30, 136, 229);
-bottom: rgba(57, 73, 171);
}
.block-signet {
-top: rgb(136, 14, 79);
-bottom: rgb(64, 7, 39);
}
.block-regtest {
-top: rgb(0, 137, 123);
-bottom: rgb(0, 96, 100);
}
.block-confirmed .block-unused {
-fx-fill: #8c8c98;
}
.block-confirmed .block-top {
-fx-fill: #696d7c;
}
.block-confirmed .block-left {
-fx-fill: #616475;
}
.block-unconfirmed .block-top {
-fx-fill: #807976;
}
.block-unconfirmed .block-left {
-fx-fill: #6f6a69;
}
.block-unconfirmed .block-unused {
-fx-opacity: 70%;
}
.block-height {
-fx-fill: derive(-fx-text-background-color, 40%);
}
.block-text {
-fx-fill: #ffffff;
}
.blocks-separator {
-fx-stroke: -fx-text-background-color;
}
.vsizeChart { .vsizeChart {
VSIZE1-2_COLOR: rgb(216, 27, 96); VSIZE1-2_COLOR: rgb(216, 27, 96);
VSIZE2-3_COLOR: rgb(142, 36, 170); VSIZE2-3_COLOR: rgb(142, 36, 170);
@ -164,3 +224,45 @@
VSIZE600-700_COLOR: rgb(51, 105, 30); VSIZE600-700_COLOR: rgb(51, 105, 30);
VSIZE700-800_COLOR: rgb(130, 119, 23); VSIZE700-800_COLOR: rgb(130, 119, 23);
} }
.block-cube {
VSIZE1-2_COLOR: #557d00;
VSIZE2-3_COLOR: #5d7d01;
VSIZE3-4_COLOR: #637d02;
VSIZE4-5_COLOR: #6d7d04;
VSIZE5-6_COLOR: #757d05;
VSIZE6-8_COLOR: #7d7d06;
VSIZE8-10_COLOR: #867d08;
VSIZE10-12_COLOR: #8c7d09;
VSIZE12-15_COLOR: #957d0b;
VSIZE15-20_COLOR: #9b7d0c;
VSIZE20-30_COLOR: #a67d0e;
VSIZE30-40_COLOR: #aa7d0f;
VSIZE40-50_COLOR: #b27d10;
VSIZE50-60_COLOR: #bb7d11;
VSIZE60-70_COLOR: #bf7d12;
VSIZE70-80_COLOR: #bf7815;
VSIZE80-90_COLOR: #bf7319;
VSIZE90-100_COLOR: #be6c1e;
VSIZE100-125_COLOR: #be6820;
VSIZE125-150_COLOR: #bd6125;
VSIZE150-175_COLOR: #bd5c28;
VSIZE175-200_COLOR: #bc552d;
VSIZE200-250_COLOR: #bc4f30;
VSIZE250-300_COLOR: #bc4a34;
VSIZE300-350_COLOR: #bb4339;
VSIZE350-400_COLOR: #bb3d3c;
VSIZE400-500_COLOR: #bb373f;
VSIZE500-600_COLOR: #ba3243;
VSIZE600-700_COLOR: #b92b48;
VSIZE700-800_COLOR: #b9254b;
VSIZE800-900_COLOR: #b8214d;
VSIZE900-1000_COLOR: #b71d4f;
VSIZE1000-1200_COLOR: #b61951;
VSIZE1200-1400_COLOR: #b41453;
VSIZE1400-1600_COLOR: #b30e55;
VSIZE1600-1800_COLOR: #b10857;
VSIZE1800-2000_COLOR: #b00259;
VSIZE2000-2200_COLOR: #ae005b;
}

View file

@ -26,6 +26,7 @@
<?import com.sparrowwallet.sparrow.wallet.OptimizationStrategy?> <?import com.sparrowwallet.sparrow.wallet.OptimizationStrategy?>
<?import com.sparrowwallet.sparrow.control.HelpLabel?> <?import com.sparrowwallet.sparrow.control.HelpLabel?>
<?import com.sparrowwallet.sparrow.control.FeeRangeSlider?> <?import com.sparrowwallet.sparrow.control.FeeRangeSlider?>
<?import com.sparrowwallet.sparrow.event.RecentBlocksView?>
<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"> <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> <center>
@ -80,6 +81,14 @@
<FeeRatesSelection fx:constant="MEMPOOL_SIZE"/> <FeeRatesSelection fx:constant="MEMPOOL_SIZE"/>
</userData> </userData>
</ToggleButton> </ToggleButton>
<ToggleButton fx:id="recentBlocksToggle" text="Recent Blocks" toggleGroup="$feeSelectionToggleGroup">
<tooltip>
<Tooltip text="Show recent and upcoming blocks"/>
</tooltip>
<userData>
<FeeRatesSelection fx:constant="RECENT_BLOCKS"/>
</userData>
</ToggleButton>
</buttons> </buttons>
</SegmentedButton> </SegmentedButton>
</HBox> </HBox>
@ -140,6 +149,7 @@
<NumberAxis side="LEFT" /> <NumberAxis side="LEFT" />
</yAxis> </yAxis>
</MempoolSizeFeeRatesChart> </MempoolSizeFeeRatesChart>
<RecentBlocksView fx:id="recentBlocksView" styleClass="feeRatesChart" AnchorPane.topAnchor="10" AnchorPane.leftAnchor="74" translateY="30" minHeight="135"/>
</AnchorPane> </AnchorPane>
</GridPane> </GridPane>
<AnchorPane> <AnchorPane>