diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index dcfd8c13..e5adc238 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -305,12 +305,6 @@ public class AppServices { if(event != null) { 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 -> { //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 newBlockEvents) { if(isConnected()) { ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents); @@ -1216,6 +1217,12 @@ public class AppServices { latestBlockHeader = event.getBlockHeader(); 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)) { fetchBlockSummaries(Collections.emptyList()); } @@ -1259,10 +1266,8 @@ public class AppServices { @Subscribe public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) { //Perform once-off fee rates retrieval to immediately change displayed rates - if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) { - feeRatesService = createFeeRatesService(); - feeRatesService.start(); - } + fetchFeeRates(); + fetchBlockSummaries(Collections.emptyList()); } @Subscribe diff --git a/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java b/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java index 1c9c9a41..bc113eb1 100644 --- a/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java +++ b/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java @@ -5,21 +5,23 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.Optional; -public class BlockSummary { +public class BlockSummary implements Comparable { private final Integer height; private final Date timestamp; private final Double medianFee; private final Integer transactionCount; + private final Integer weight; 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.timestamp = timestamp; this.medianFee = medianFee; this.transactionCount = transactionCount; + this.weight = weight; } public Integer getHeight() { @@ -38,6 +40,10 @@ public class BlockSummary { return transactionCount == null ? Optional.empty() : Optional.of(transactionCount); } + public Optional getWeight() { + return weight == null ? Optional.empty() : Optional.of(weight); + } + private static long calculateElapsedSeconds(long timestampUtc) { Instant timestampInstant = Instant.ofEpochMilli(timestampUtc); Instant nowInstant = Instant.now(); @@ -62,4 +68,9 @@ public class BlockSummary { public String toString() { return getElapsed() + ":" + getMedianFee(); } + + @Override + public int compareTo(BlockSummary o) { + return o.height - height; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/event/BlockCube.java b/src/main/java/com/sparrowwallet/sparrow/event/BlockCube.java new file mode 100644 index 00000000..151202a0 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/BlockCube.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/event/RecentBlocksView.java b/src/main/java/com/sparrowwallet/sparrow/event/RecentBlocksView.java new file mode 100644 index 00000000..c2c26dd4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/RecentBlocksView.java @@ -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> 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 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 latestBlocks, Double currentFeeRate) { + if(getCubes().isEmpty()) { + List 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 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 getCubes() { + return cubesProperty.get(); + } + + public ObjectProperty> cubesProperty() { + return cubesProperty; + } + + public void setCubes(List cubes) { + this.cubesProperty.set(cubes); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 8d5dda01..b9b8e871 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -1945,13 +1945,18 @@ public class ElectrumServer { if(startHeight == 0 || totalBlocks > 1 || startHeight > maxHeight + 1) { if(isBlockstorm(totalBlocks)) { - for(int height = maxHeight + 1; height < endHeight; height++) { - blockSummaryMap.put(height, new BlockSummary(height, new Date())); + int start = Math.max(maxHeight + 1, endHeight - 15); + for(int height = start; height <= endHeight; height++) { + blockSummaryMap.put(height, new BlockSummary(height, new Date(), 1.0d, 0, 0)); } } else { blockSummaryMap.putAll(electrumServer.getRecentBlockSummaryMap()); } - } else { + } + + List events = new ArrayList<>(newBlockEvents); + events.removeIf(event -> blockSummaryMap.containsKey(event.getHeight())); + if(!events.isEmpty()) { for(NewBlockEvent event : newBlockEvents) { blockSummaryMap.putAll(electrumServer.getBlockSummaryMap(event.getHeight(), event.getBlockHeader())); } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java index 9e7bbd79..4510f8b5 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java @@ -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() { return extras == null ? null : extras.medianFee(); } @@ -294,7 +294,7 @@ public enum FeeRatesSource { if(height == null || timestamp == null) { 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); } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/FeeRatesSelection.java b/src/main/java/com/sparrowwallet/sparrow/wallet/FeeRatesSelection.java index 9e75e9d8..dc14e3f0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/FeeRatesSelection.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/FeeRatesSelection.java @@ -1,7 +1,7 @@ package com.sparrowwallet.sparrow.wallet; 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; diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java index bbf8e8f7..c3542619 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SendController.java @@ -13,10 +13,7 @@ import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.*; -import com.sparrowwallet.sparrow.UnitFormat; -import com.sparrowwallet.sparrow.AppServices; -import com.sparrowwallet.sparrow.CurrencyRate; -import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.*; import com.sparrowwallet.sparrow.control.*; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; @@ -28,6 +25,7 @@ import com.sparrowwallet.sparrow.paynym.PayNymService; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; @@ -78,6 +76,9 @@ public class SendController extends WalletFormController implements Initializabl @FXML private ToggleButton mempoolSizeToggle; + @FXML + private ToggleButton recentBlocksToggle; + @FXML private Field targetBlocksField; @@ -117,6 +118,9 @@ public class SendController extends WalletFormController implements Initializabl @FXML private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart; + @FXML + private RecentBlocksView recentBlocksView; + @FXML private TransactionDiagram transactionDiagram; @@ -162,6 +166,8 @@ public class SendController extends WalletFormController implements Initializabl private final ObjectProperty replacedTransactionProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty feeRatesSelectionProperty = new SimpleObjectProperty<>(null); + private final List opReturnsList = new ArrayList<>(); private final Set excludedChangeNodes = new HashSet<>(); @@ -299,6 +305,7 @@ public class SendController extends WalletFormController implements Initializabl feeRange.valueProperty().addListener(feeRangeListener); blockTargetFeeRatesChart.managedProperty().bind(blockTargetFeeRatesChart.visibleProperty()); + blockTargetFeeRatesChart.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.BLOCK_TARGET)); blockTargetFeeRatesChart.initialize(); Map targetBlocksFeeRates = getTargetBlocksFeeRates(); if(targetBlocksFeeRates != null) { @@ -308,20 +315,41 @@ public class SendController extends WalletFormController implements Initializabl } mempoolSizeFeeRatesChart.managedProperty().bind(mempoolSizeFeeRatesChart.visibleProperty()); - mempoolSizeFeeRatesChart.visibleProperty().bind(blockTargetFeeRatesChart.visibleProperty().not()); + mempoolSizeFeeRatesChart.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.MEMPOOL_SIZE)); mempoolSizeFeeRatesChart.initialize(); Map> mempoolHistogram = getMempoolHistogram(); if(mempoolHistogram != null) { mempoolSizeFeeRatesChart.update(mempoolHistogram); } + recentBlocksView.managedProperty().bind(recentBlocksView.visibleProperty()); + recentBlocksView.visibleProperty().bind(Bindings.equal(feeRatesSelectionProperty, FeeRatesSelection.RECENT_BLOCKS)); + List 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 == null ? FeeRatesSelection.MEMPOOL_SIZE : feeRatesSelection); + feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.RECENT_BLOCKS : feeRatesSelection); cpfpFeeRate.managedProperty().bind(cpfpFeeRate.visibleProperty()); cpfpFeeRate.setVisible(false); setDefaultFeeRate(); - updateFeeRateSelection(feeRatesSelection); - feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle); + feeRatesSelectionProperty.set(feeRatesSelection); + feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : + (feeRatesSelection == FeeRatesSelection.MEMPOOL_SIZE ? mempoolSizeToggle : recentBlocksToggle)); feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> { if(newValue != null) { 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())); } - 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() { int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget); Double defaultRate = getTargetBlocksFeeRates().get(defaultTarget); targetBlocks.setValue(index); blockTargetFeeRatesChart.select(defaultTarget); + recentBlocksView.updateFeeRate(defaultRate); setFeeRangeRate(defaultRate); setFeeRate(getFeeRangeRate()); if(Network.get().equals(Network.MAINNET) && defaultRate == getFallbackFeeRate()) { @@ -1411,10 +1428,15 @@ public class SendController extends WalletFormController implements Initializabl @Subscribe public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) { 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 public void spendUtxos(SpendUtxoEvent event) { if((event.getUtxos() == null || !event.getUtxos().isEmpty()) && event.getWallet().equals(getWalletForm().getWallet())) { diff --git a/src/main/resources/com/sparrowwallet/sparrow/darktheme.css b/src/main/resources/com/sparrowwallet/sparrow/darktheme.css index e4926d22..bc8b8fa6 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/darktheme.css +++ b/src/main/resources/com/sparrowwallet/sparrow/darktheme.css @@ -343,4 +343,32 @@ HorizontalHeaderColumn > TableColumnHeader.column-header.table-column{ #grid .spreadsheet-cell.selection { -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; } \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css index c0f4f67e..d05f4ae2 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.css @@ -132,6 +132,66 @@ -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 { VSIZE1-2_COLOR: rgb(216, 27, 96); VSIZE2-3_COLOR: rgb(142, 36, 170); @@ -164,3 +224,45 @@ VSIZE600-700_COLOR: rgb(51, 105, 30); 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; +} + diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml index 04f01998..cf9bf633 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/send.fxml @@ -26,6 +26,7 @@ +
@@ -80,6 +81,14 @@ + + + + + + + + @@ -140,6 +149,7 @@ +