add block summary service

This commit is contained in:
Craig Raw 2025-05-05 14:43:42 +02:00
parent c77f52f7f6
commit 3d85491e6b
9 changed files with 460 additions and 10 deletions

View file

@ -26,6 +26,8 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*; import com.sparrowwallet.sparrow.net.*;
import io.reactivex.rxjavafx.schedulers.JavaFxScheduler;
import io.reactivex.subjects.PublishSubject;
import javafx.application.Application; import javafx.application.Application;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
@ -43,7 +45,6 @@ import javafx.scene.Scene;
import javafx.scene.control.*; import javafx.scene.control.*;
import javafx.scene.control.Dialog; import javafx.scene.control.Dialog;
import javafx.scene.image.Image; import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCode;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import javafx.stage.Screen; import javafx.stage.Screen;
@ -67,6 +68,8 @@ import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*; import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
@ -105,6 +108,8 @@ public class AppServices {
private TrayManager trayManager; private TrayManager trayManager;
private final PublishSubject<NewBlockEvent> newBlockSubject = PublishSubject.create();
private static Image windowIcon; private static Image windowIcon;
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false); private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false);
@ -127,6 +132,8 @@ public class AppServices {
private static BlockHeader latestBlockHeader; private static BlockHeader latestBlockHeader;
private static final Map<Integer, BlockSummary> blockSummaries = new ConcurrentHashMap<>();
private static Map<Integer, Double> targetBlockFeeRates; private static Map<Integer, Double> targetBlockFeeRates;
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>(); private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
@ -183,6 +190,12 @@ public class AppServices {
private AppServices(Application application, InteractionServices interactionServices) { private AppServices(Application application, InteractionServices interactionServices) {
this.application = application; this.application = application;
this.interactionServices = interactionServices; this.interactionServices = interactionServices;
newBlockSubject.buffer(4, TimeUnit.SECONDS)
.filter(newBlockEvents -> !newBlockEvents.isEmpty())
.observeOn(JavaFxScheduler.platform())
.subscribe(this::fetchBlockSummaries, exception -> log.error("Error fetching block summaries", exception));
EventManager.get().register(this); EventManager.get().register(this);
} }
@ -481,6 +494,19 @@ public class AppServices {
} }
} }
private void fetchBlockSummaries(List<NewBlockEvent> newBlockEvents) {
if(isConnected()) {
ElectrumServer.BlockSummaryService blockSummaryService = new ElectrumServer.BlockSummaryService(newBlockEvents);
blockSummaryService.setOnSucceeded(_ -> {
EventManager.get().post(blockSummaryService.getValue());
});
blockSummaryService.setOnFailed(failedState -> {
log.error("Error fetching block summaries", failedState.getSource().getException());
});
blockSummaryService.start();
}
}
public static boolean isTorRunning() { public static boolean isTorRunning() {
return Tor.getDefault() != null; return Tor.getDefault() != null;
} }
@ -706,6 +732,10 @@ public class AppServices {
return latestBlockHeader; return latestBlockHeader;
} }
public static Map<Integer, BlockSummary> getBlockSummaries() {
return blockSummaries;
}
public static Double getDefaultFeeRate() { public static Double getDefaultFeeRate() {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget); return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget);
@ -1185,6 +1215,10 @@ public class AppServices {
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE); minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader(); latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer(); Config.get().addRecentServer();
if(!blockSummaries.containsKey(currentBlockHeight)) {
fetchBlockSummaries(Collections.emptyList());
}
} }
@Subscribe @Subscribe
@ -1199,6 +1233,15 @@ public class AppServices {
latestBlockHeader = event.getBlockHeader(); latestBlockHeader = event.getBlockHeader();
String status = "Updating to new block height " + event.getHeight(); String status = "Updating to new block height " + event.getHeight();
EventManager.get().post(new StatusEvent(status)); EventManager.get().post(new StatusEvent(status));
newBlockSubject.onNext(event);
}
@Subscribe
public void blockSummary(BlockSummaryEvent event) {
blockSummaries.putAll(event.getBlockSummaryMap());
if(AppServices.currentBlockHeight != null) {
blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5);
}
} }
@Subscribe @Subscribe

View file

@ -0,0 +1,65 @@
package com.sparrowwallet.sparrow;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Optional;
public class BlockSummary {
private final Integer height;
private final Date timestamp;
private final Double medianFee;
private final Integer transactionCount;
public BlockSummary(Integer height, Date timestamp) {
this(height, timestamp, null, null);
}
public BlockSummary(Integer height, Date timestamp, Double medianFee, Integer transactionCount) {
this.height = height;
this.timestamp = timestamp;
this.medianFee = medianFee;
this.transactionCount = transactionCount;
}
public Integer getHeight() {
return height;
}
public Date getTimestamp() {
return timestamp;
}
public Optional<Double> getMedianFee() {
return medianFee == null ? Optional.empty() : Optional.of(medianFee);
}
public Optional<Integer> getTransactionCount() {
return transactionCount == null ? Optional.empty() : Optional.of(transactionCount);
}
private static long calculateElapsedSeconds(long timestampUtc) {
Instant timestampInstant = Instant.ofEpochMilli(timestampUtc);
Instant nowInstant = Instant.now();
return ChronoUnit.SECONDS.between(timestampInstant, nowInstant);
}
public String getElapsed() {
long elapsed = calculateElapsedSeconds(getTimestamp().getTime());
if(elapsed < 0) {
return "now";
} else if(elapsed < 60) {
return elapsed + "s";
} else if(elapsed < 3600) {
return elapsed / 60 + "m";
} else if(elapsed < 86400) {
return elapsed / 3600 + "h";
} else {
return elapsed / 86400 + "d";
}
}
public String toString() {
return getElapsed() + ":" + getMedianFee();
}
}

View file

@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.sparrow.BlockSummary;
import java.util.Map;
public class BlockSummaryEvent {
private final Map<Integer, BlockSummary> blockSummaryMap;
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap) {
this.blockSummaryMap = blockSummaryMap;
}
public Map<Integer, BlockSummary> getBlockSummaryMap() {
return blockSummaryMap;
}
}

View file

@ -151,6 +151,27 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
} }
} }
@Override
public Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes) {
PagedBatchRequestBuilder<String, Boolean> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(String.class).returnType(Boolean.class);
for(String scriptHash : scriptHashes) {
batchRequest.add(scriptHash, "blockchain.scripthash.unsubscribe", scriptHash);
}
try {
return batchRequest.execute();
} catch(JsonRpcBatchException e) {
log.warn("Failed to unsubscribe from script hashes: " + e.getErrors().keySet(), e);
Map<String, Boolean> unsubscribedScriptHashes = scriptHashes.stream().collect(Collectors.toMap(s -> s, _ -> true));
unsubscribedScriptHashes.keySet().removeIf(scriptHash -> e.getErrors().containsKey(scriptHash));
return unsubscribedScriptHashes;
} catch(Exception e) {
log.warn("Failed to unsubscribe from script hashes: " + scriptHashes, e);
return Collections.emptyMap();
}
}
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) { public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {

View file

@ -12,6 +12,7 @@ import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.BlockSummary;
import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config; import com.sparrowwallet.sparrow.io.Config;
@ -32,11 +33,13 @@ import org.slf4j.LoggerFactory;
import java.io.*; import java.io.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream; import java.util.stream.Stream;
public class ElectrumServer { public class ElectrumServer {
@ -946,6 +949,71 @@ public class ElectrumServer {
return Transaction.DEFAULT_MIN_RELAY_FEE; return Transaction.DEFAULT_MIN_RELAY_FEE;
} }
public Map<Integer, BlockSummary> getRecentBlockSummaryMap() throws ServerException {
return getBlockSummaryMap(null, null);
}
public Map<Integer, BlockSummary> getBlockSummaryMap(Integer height, BlockHeader blockHeader) throws ServerException {
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get())) {
try {
if(blockHeader == null) {
return feeRatesSource.getRecentBlockSummaries();
} else {
Map<Integer, BlockSummary> blockSummaryMap = new HashMap<>();
BlockSummary blockSummary = feeRatesSource.getBlockSummary(Sha256Hash.twiceOf(blockHeader.bitcoinSerialize()));
if(blockSummary != null && blockSummary.getHeight() != null) {
blockSummaryMap.put(blockSummary.getHeight(), blockSummary);
}
return blockSummaryMap;
}
} catch(Exception e) {
return getServerBlockSummaryMap(height, blockHeader);
}
} else {
return getServerBlockSummaryMap(height, blockHeader);
}
}
private Map<Integer, BlockSummary> getServerBlockSummaryMap(Integer height, BlockHeader blockHeader) throws ServerException {
if(blockHeader == null || height == null) {
Integer current = AppServices.getCurrentBlockHeight();
if(current == null) {
return Collections.emptyMap();
}
Set<BlockTransactionHash> references = IntStream.range(current - 4, current + 1)
.mapToObj(i -> new BlockTransaction(null, i, null, null, null)).collect(Collectors.toSet());
Map<Integer, BlockHeader> blockHeaders = getBlockHeaders(null, references);
return blockHeaders.keySet().stream()
.collect(Collectors.toMap(java.util.function.Function.identity(), v -> new BlockSummary(v, blockHeaders.get(v).getTimeAsDate())));
} else {
Map<Integer, BlockSummary> blockSummaryMap = new HashMap<>();
blockSummaryMap.put(height, new BlockSummary(height, blockHeader.getTimeAsDate()));
return blockSummaryMap;
}
}
public List<BlockTransaction> getRecentMempoolTransactions() {
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(feeRatesSource.supportsNetwork(Network.get())) {
try {
List<BlockTransactionHash> recentTransactions = feeRatesSource.getRecentMempoolTransactions();
Map<BlockTransactionHash, Transaction> setReferences = new HashMap<>();
setReferences.put(recentTransactions.getFirst(), null);
Map<Sha256Hash, BlockTransaction> transactions = getTransactions(null, setReferences, Collections.emptyMap());
return transactions.values().stream().filter(blxTx -> blxTx.getTransaction() != null).toList();
} catch(Exception e) {
return Collections.emptyList();
}
} else {
return Collections.emptyList();
}
}
public Sha256Hash broadcastTransactionPrivately(Transaction transaction) throws ServerException { public Sha256Hash broadcastTransactionPrivately(Transaction transaction) throws ServerException {
//If Tor proxy is configured, try all external broadcast sources in random order before falling back to connected Electrum server //If Tor proxy is configured, try all external broadcast sources in random order before falling back to connected Electrum server
if(AppServices.isUsingProxy()) { if(AppServices.isUsingProxy()) {
@ -1809,6 +1877,85 @@ public class ElectrumServer {
} }
} }
public static class BlockSummaryService extends Service<BlockSummaryEvent> {
private final List<NewBlockEvent> newBlockEvents;
public BlockSummaryService(List<NewBlockEvent> newBlockEvents) {
this.newBlockEvents = newBlockEvents;
}
@Override
protected Task<BlockSummaryEvent> createTask() {
return new Task<>() {
protected BlockSummaryEvent call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<Integer, BlockSummary> blockSummaryMap = new LinkedHashMap<>();
int maxHeight = AppServices.getBlockSummaries().keySet().stream().mapToInt(Integer::intValue).max().orElse(0);
int startHeight = newBlockEvents.stream().mapToInt(NewBlockEvent::getHeight).min().orElse(0);
int endHeight = newBlockEvents.stream().mapToInt(NewBlockEvent::getHeight).max().orElse(0);
int totalBlocks = Math.max(0, endHeight - maxHeight);
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()));
}
} else {
blockSummaryMap.putAll(electrumServer.getRecentBlockSummaryMap());
}
} else {
for(NewBlockEvent event : newBlockEvents) {
blockSummaryMap.putAll(electrumServer.getBlockSummaryMap(event.getHeight(), event.getBlockHeader()));
}
}
Config config = Config.get();
if(!isBlockstorm(totalBlocks) && !AppServices.isUsingProxy() && config.getServer().getProtocol().equals(Protocol.SSL)
&& (config.getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER || config.getServerType() == ServerType.ELECTRUM_SERVER)) {
subscribeRecent(electrumServer);
}
return new BlockSummaryEvent(blockSummaryMap);
}
};
}
private boolean isBlockstorm(int totalBlocks) {
return Network.get() != Network.MAINNET && totalBlocks > 2;
}
private final static Set<String> subscribedRecent = Collections.newSetFromMap(new ConcurrentHashMap<>());
private void subscribeRecent(ElectrumServer electrumServer) {
Set<String> unsubscribeScriptHashes = new HashSet<>(subscribedRecent);
unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey);
electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes);
subscribedRecent.removeAll(unsubscribeScriptHashes);
Map<String, String> subscribeScriptHashes = new HashMap<>();
List<BlockTransaction> recentTransactions = electrumServer.getRecentMempoolTransactions();
for(BlockTransaction blkTx : recentTransactions) {
for(int i = 0; i < blkTx.getTransaction().getOutputs().size() && subscribeScriptHashes.size() < 10; i++) {
TransactionOutput txOutput = blkTx.getTransaction().getOutputs().get(i);
String scriptHash = getScriptHash(txOutput);
if(!subscribedScriptHashes.containsKey(scriptHash)) {
subscribeScriptHashes.put("m/" + i, getScriptHash(txOutput));
}
}
}
if(!subscribeScriptHashes.isEmpty()) {
try {
electrumServerRpc.subscribeScriptHashes(transport, null, subscribeScriptHashes);
subscribedRecent.addAll(subscribeScriptHashes.values());
} catch(ElectrumServerRpcException e) {
log.debug("Error subscribing to recent mempool transactions", e);
}
}
}
}
public static class WalletDiscoveryService extends Service<Optional<Wallet>> { public static class WalletDiscoveryService extends Service<Optional<Wallet>> {
private final List<Wallet> wallets; private final List<Wallet> wallets;

View file

@ -22,6 +22,8 @@ public interface ElectrumServerRpc {
Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes); Map<String, String> subscribeScriptHashes(Transport transport, Wallet wallet, Map<String, String> pathScriptHashes);
Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes);
Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights); Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights);
Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids); Map<String, String> getTransactions(Transport transport, Wallet wallet, Set<String> txids);

View file

@ -1,13 +1,16 @@
package com.sparrowwallet.sparrow.net; package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.drongo.Network; import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.BlockSummary;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.Collections; import java.util.*;
import java.util.LinkedHashMap;
import java.util.Map;
public enum FeeRatesSource { public enum FeeRatesSource {
ELECTRUM_SERVER("Server", false) { ELECTRUM_SERVER("Server", false) {
@ -24,11 +27,34 @@ public enum FeeRatesSource {
MEMPOOL_SPACE("mempool.space", true) { MEMPOOL_SPACE("mempool.space", true) {
@Override @Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) { public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/fees/recommended" : "https://mempool.space/api/v1/fees/recommended"; String url = getApiUrl() + "v1/fees/recommended";
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
@Override
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
String url = getApiUrl() + "v1/block/" + Utils.bytesToHex(blockId.getReversedBytes());
return requestBlockSummary(this, url);
}
@Override
public Map<Integer, BlockSummary> getRecentBlockSummaries() throws Exception {
String url = getApiUrl() + "v1/blocks";
return requestBlockSummaries(this, url);
}
@Override
public List<BlockTransactionHash> getRecentMempoolTransactions() throws Exception {
String url = getApiUrl() + "mempool/recent";
return requestRecentMempoolTransactions(this, url);
}
private String getApiUrl() {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/" : "https://mempool.space/api/";
if(Network.get() != Network.MAINNET && supportsNetwork(Network.get())) { if(Network.get() != Network.MAINNET && supportsNetwork(Network.get())) {
url = url.replace("/api/", "/" + Network.get().getName() + "/api/"); url = url.replace("/api/", "/" + Network.get().getName() + "/api/");
} }
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url); return url;
} }
@Override @Override
@ -101,6 +127,18 @@ public enum FeeRatesSource {
public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates); public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates);
public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception {
throw new UnsupportedOperationException(name + " does not support block summaries");
}
public Map<Integer, BlockSummary> getRecentBlockSummaries() throws Exception {
throw new UnsupportedOperationException(name + " does not support block summaries");
}
public List<BlockTransactionHash> getRecentMempoolTransactions() throws Exception {
throw new UnsupportedOperationException(name + " does not support recent mempool transactions");
}
public abstract boolean supportsNetwork(Network network); public abstract boolean supportsNetwork(Network network);
public String getName() { public String getName() {
@ -158,6 +196,80 @@ public enum FeeRatesSource {
return httpClientService.requestJson(url, ThreeTierRates.class, null); return httpClientService.requestJson(url, ThreeTierRates.class, null);
} }
protected static BlockSummary requestBlockSummary(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) {
log.info("Requesting block summary from " + url);
}
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolBlockSummary mempoolBlockSummary = feeRatesSource.requestBlockSummary(url, httpClientService);
return mempoolBlockSummary.toBlockSummary();
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving block summary from " + url, e);
} else {
log.warn("Error retrieving block summary from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolBlockSummary requestBlockSummary(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolBlockSummary.class, null);
}
protected static Map<Integer, BlockSummary> requestBlockSummaries(FeeRatesSource feeRatesSource, String url) throws Exception {
if(log.isInfoEnabled()) {
log.info("Requesting block summaries from " + url);
}
Map<Integer, BlockSummary> blockSummaryMap = new LinkedHashMap<>();
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolBlockSummary[] blockSummaries = feeRatesSource.requestBlockSummaries(url, httpClientService);
for(MempoolBlockSummary blockSummary : blockSummaries) {
if(blockSummary.height != null) {
blockSummaryMap.put(blockSummary.height, blockSummary.toBlockSummary());
}
}
return blockSummaryMap;
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving block summaries from " + url, e);
} else {
log.warn("Error retrieving block summaries from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolBlockSummary[] requestBlockSummaries(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolBlockSummary[].class, null);
}
protected List<BlockTransactionHash> requestRecentMempoolTransactions(FeeRatesSource feeRatesSource, String url) throws Exception {
HttpClientService httpClientService = AppServices.getHttpClientService();
try {
MempoolRecentTransaction[] recentTransactions = feeRatesSource.requestRecentMempoolTransactions(url, httpClientService);
return Arrays.stream(recentTransactions).sorted().map(tx -> (BlockTransactionHash)new BlockTransaction(tx.txid, 0, null, tx.fee, null)).toList();
} catch (Exception e) {
if(log.isDebugEnabled()) {
log.warn("Error retrieving recent mempool transactions from " + url, e);
} else {
log.warn("Error retrieving recent mempool from " + url + " (" + e.getMessage() + ")");
}
throw e;
}
}
protected MempoolRecentTransaction[] requestRecentMempoolTransactions(String url, HttpClientService httpClientService) throws Exception {
return httpClientService.requestJson(url, MempoolRecentTransaction[].class, null);
}
@Override @Override
public String toString() { public String toString() {
return name; return name;
@ -172,4 +284,30 @@ public enum FeeRatesSource {
return new ThreeTierRates(recommended_fee_099/1000, recommended_fee_090/1000, recommended_fee_050/1000, null); return new ThreeTierRates(recommended_fee_099/1000, recommended_fee_090/1000, recommended_fee_050/1000, null);
} }
} }
protected record MempoolBlockSummary(String id, Integer height, Long timestamp, Integer tx_count, MempoolBlockSummaryExtras extras) {
public Double getMedianFee() {
return extras == null ? null : extras.medianFee();
}
public BlockSummary toBlockSummary() {
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);
}
}
private record MempoolBlockSummaryExtras(Double medianFee) {}
protected record MempoolRecentTransaction(Sha256Hash txid, Long fee, Long vsize) implements Comparable<MempoolRecentTransaction> {
private Double getFeeRate() {
return fee == null || vsize == null ? 0.0d : (double)fee / vsize;
}
@Override
public int compareTo(MempoolRecentTransaction o) {
return Double.compare(o.getFeeRate(), getFeeRate());
}
}
} }

View file

@ -123,9 +123,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
for(String path : pathScriptHashes.keySet()) { for(String path : pathScriptHashes.keySet()) {
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path)); EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path));
try { try {
String scriptHash = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> String scriptHashStatus = new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable()); client.createRequest().returnAs(String.class).method("blockchain.scripthash.subscribe").id(idCounter.incrementAndGet()).params(pathScriptHashes.get(path)).executeNullable());
result.put(path, scriptHash); result.put(path, scriptHashStatus);
} catch(Exception e) { } catch(Exception e) {
//Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed. //Even if we have some successes, failure to subscribe for all script hashes will result in outdated wallet view. Don't proceed.
throw new ElectrumServerRpcException("Failed to subscribe to path: " + path, e); throw new ElectrumServerRpcException("Failed to subscribe to path: " + path, e);
@ -135,6 +135,24 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
return result; return result;
} }
@Override
public Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes) {
JsonRpcClient client = new JsonRpcClient(transport);
Map<String, Boolean> result = new LinkedHashMap<>();
for(String scriptHash : scriptHashes) {
try {
Boolean wasSubscribed = new RetryLogic<Boolean>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
client.createRequest().returnAs(Boolean.class).method("blockchain.scripthash.unsubscribe").id(idCounter.incrementAndGet()).params(scriptHash).executeNullable());
result.put(scriptHash, wasSubscribed);
} catch(Exception e) {
log.warn("Failed to unsubscribe from script hash: " + scriptHash, e);
}
}
return result;
}
@Override @Override
public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) { public Map<Integer, String> getBlockHeaders(Transport transport, Wallet wallet, Set<Integer> blockHeights) {
JsonRpcClient client = new JsonRpcClient(transport); JsonRpcClient client = new JsonRpcClient(transport);

View file

@ -28,8 +28,7 @@ public class SubscriptionService {
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) { public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) {
List<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash); List<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
if(existingStatuses == null) { if(existingStatuses == null) {
log.debug("Received script hash status update for unsubscribed script hash: " + scriptHash); log.trace("Received script hash status update for non-wallet script hash: " + scriptHash);
ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status);
} else if(status != null && existingStatuses.contains(status)) { } else if(status != null && existingStatuses.contains(status)) {
log.debug("Received script hash status update, but status has not changed"); log.debug("Received script hash status update, but status has not changed");
return; return;