diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java index 36222a75..dcfd8c13 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java @@ -26,6 +26,8 @@ import com.sparrowwallet.sparrow.control.TrayManager; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.net.*; +import io.reactivex.rxjavafx.schedulers.JavaFxScheduler; +import io.reactivex.subjects.PublishSubject; import javafx.application.Application; import javafx.application.Platform; import javafx.beans.property.BooleanProperty; @@ -43,7 +45,6 @@ import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.control.Dialog; import javafx.scene.image.Image; -import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.text.Font; import javafx.stage.Screen; @@ -67,6 +68,8 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*; @@ -105,6 +108,8 @@ public class AppServices { private TrayManager trayManager; + private final PublishSubject newBlockSubject = PublishSubject.create(); + private static Image windowIcon; private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false); @@ -127,6 +132,8 @@ public class AppServices { private static BlockHeader latestBlockHeader; + private static final Map blockSummaries = new ConcurrentHashMap<>(); + private static Map targetBlockFeeRates; private static final TreeMap> mempoolHistogram = new TreeMap<>(); @@ -183,6 +190,12 @@ public class AppServices { private AppServices(Application application, InteractionServices interactionServices) { this.application = application; 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); } @@ -481,6 +494,19 @@ public class AppServices { } } + private void fetchBlockSummaries(List 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() { return Tor.getDefault() != null; } @@ -706,6 +732,10 @@ public class AppServices { return latestBlockHeader; } + public static Map getBlockSummaries() { + return blockSummaries; + } + public static Double getDefaultFeeRate() { int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1); return getTargetBlockFeeRates() == null ? getFallbackFeeRate() : getTargetBlockFeeRates().get(defaultTarget); @@ -1185,6 +1215,10 @@ public class AppServices { minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE); latestBlockHeader = event.getBlockHeader(); Config.get().addRecentServer(); + + if(!blockSummaries.containsKey(currentBlockHeight)) { + fetchBlockSummaries(Collections.emptyList()); + } } @Subscribe @@ -1199,6 +1233,15 @@ public class AppServices { latestBlockHeader = event.getBlockHeader(); String status = "Updating to new block height " + event.getHeight(); 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 diff --git a/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java b/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java new file mode 100644 index 00000000..1c9c9a41 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/BlockSummary.java @@ -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 getMedianFee() { + return medianFee == null ? Optional.empty() : Optional.of(medianFee); + } + + public Optional 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(); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java new file mode 100644 index 00000000..4cd74e14 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/BlockSummaryEvent.java @@ -0,0 +1,17 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.sparrow.BlockSummary; + +import java.util.Map; + +public class BlockSummaryEvent { + private final Map blockSummaryMap; + + public BlockSummaryEvent(Map blockSummaryMap) { + this.blockSummaryMap = blockSummaryMap; + } + + public Map getBlockSummaryMap() { + return blockSummaryMap; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java index d9b66ad3..588541ed 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java @@ -151,6 +151,27 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc { } } + @Override + public Map unsubscribeScriptHashes(Transport transport, Set scriptHashes) { + PagedBatchRequestBuilder 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 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 @SuppressWarnings("unchecked") public Map getBlockHeaders(Transport transport, Wallet wallet, Set blockHeights) { diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 4850d2a9..5b1d5201 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java @@ -12,6 +12,7 @@ import com.sparrowwallet.drongo.bip47.PaymentCode; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; +import com.sparrowwallet.sparrow.BlockSummary; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.io.Config; @@ -32,11 +33,13 @@ import org.slf4j.LoggerFactory; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.stream.Stream; public class ElectrumServer { @@ -946,6 +949,71 @@ public class ElectrumServer { return Transaction.DEFAULT_MIN_RELAY_FEE; } + public Map getRecentBlockSummaryMap() throws ServerException { + return getBlockSummaryMap(null, null); + } + + public Map 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 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 getServerBlockSummaryMap(Integer height, BlockHeader blockHeader) throws ServerException { + if(blockHeader == null || height == null) { + Integer current = AppServices.getCurrentBlockHeight(); + if(current == null) { + return Collections.emptyMap(); + } + Set references = IntStream.range(current - 4, current + 1) + .mapToObj(i -> new BlockTransaction(null, i, null, null, null)).collect(Collectors.toSet()); + Map 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 blockSummaryMap = new HashMap<>(); + blockSummaryMap.put(height, new BlockSummary(height, blockHeader.getTimeAsDate())); + return blockSummaryMap; + } + } + + public List getRecentMempoolTransactions() { + FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource(); + feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource); + + if(feeRatesSource.supportsNetwork(Network.get())) { + try { + List recentTransactions = feeRatesSource.getRecentMempoolTransactions(); + Map setReferences = new HashMap<>(); + setReferences.put(recentTransactions.getFirst(), null); + Map 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 { //If Tor proxy is configured, try all external broadcast sources in random order before falling back to connected Electrum server if(AppServices.isUsingProxy()) { @@ -1809,6 +1877,85 @@ public class ElectrumServer { } } + public static class BlockSummaryService extends Service { + private final List newBlockEvents; + + public BlockSummaryService(List newBlockEvents) { + this.newBlockEvents = newBlockEvents; + } + + @Override + protected Task createTask() { + return new Task<>() { + protected BlockSummaryEvent call() throws ServerException { + ElectrumServer electrumServer = new ElectrumServer(); + Map 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 subscribedRecent = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + private void subscribeRecent(ElectrumServer electrumServer) { + Set unsubscribeScriptHashes = new HashSet<>(subscribedRecent); + unsubscribeScriptHashes.removeIf(subscribedScriptHashes::containsKey); + electrumServerRpc.unsubscribeScriptHashes(transport, unsubscribeScriptHashes); + subscribedRecent.removeAll(unsubscribeScriptHashes); + + Map subscribeScriptHashes = new HashMap<>(); + List 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> { private final List wallets; diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java index cc30e196..92e2be52 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java @@ -22,6 +22,8 @@ public interface ElectrumServerRpc { Map subscribeScriptHashes(Transport transport, Wallet wallet, Map pathScriptHashes); + Map unsubscribeScriptHashes(Transport transport, Set scriptHashes); + Map getBlockHeaders(Transport transport, Wallet wallet, Set blockHeights); Map getTransactions(Transport transport, Wallet wallet, Set txids); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java index 3466f3db..9e7bbd79 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/FeeRatesSource.java @@ -1,13 +1,16 @@ package com.sparrowwallet.sparrow.net; 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.BlockSummary; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.*; public enum FeeRatesSource { ELECTRUM_SERVER("Server", false) { @@ -24,11 +27,34 @@ public enum FeeRatesSource { MEMPOOL_SPACE("mempool.space", true) { @Override public Map getBlockTargetFeeRates(Map 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 getRecentBlockSummaries() throws Exception { + String url = getApiUrl() + "v1/blocks"; + return requestBlockSummaries(this, url); + } + + @Override + public List 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())) { url = url.replace("/api/", "/" + Network.get().getName() + "/api/"); } - return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url); + return url; } @Override @@ -101,6 +127,18 @@ public enum FeeRatesSource { public abstract Map getBlockTargetFeeRates(Map defaultblockTargetFeeRates); + public BlockSummary getBlockSummary(Sha256Hash blockId) throws Exception { + throw new UnsupportedOperationException(name + " does not support block summaries"); + } + + public Map getRecentBlockSummaries() throws Exception { + throw new UnsupportedOperationException(name + " does not support block summaries"); + } + + public List getRecentMempoolTransactions() throws Exception { + throw new UnsupportedOperationException(name + " does not support recent mempool transactions"); + } + public abstract boolean supportsNetwork(Network network); public String getName() { @@ -158,6 +196,80 @@ public enum FeeRatesSource { 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 requestBlockSummaries(FeeRatesSource feeRatesSource, String url) throws Exception { + if(log.isInfoEnabled()) { + log.info("Requesting block summaries from " + url); + } + + Map 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 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 public String toString() { 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); } } + + 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 { + 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()); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java index f407ea5f..d3947a48 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java @@ -123,9 +123,9 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { for(String path : pathScriptHashes.keySet()) { EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Finding transactions for " + path)); try { - String scriptHash = new RetryLogic(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() -> + String scriptHashStatus = new RetryLogic(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()); - result.put(path, scriptHash); + result.put(path, scriptHashStatus); } 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. throw new ElectrumServerRpcException("Failed to subscribe to path: " + path, e); @@ -135,6 +135,24 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc { return result; } + @Override + public Map unsubscribeScriptHashes(Transport transport, Set scriptHashes) { + JsonRpcClient client = new JsonRpcClient(transport); + + Map result = new LinkedHashMap<>(); + for(String scriptHash : scriptHashes) { + try { + Boolean wasSubscribed = new RetryLogic(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 public Map getBlockHeaders(Transport transport, Wallet wallet, Set blockHeights) { JsonRpcClient client = new JsonRpcClient(transport); diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java index 44d8de37..1410a0a0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java +++ b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java @@ -28,8 +28,7 @@ public class SubscriptionService { public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) { List existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash); if(existingStatuses == null) { - log.debug("Received script hash status update for unsubscribed script hash: " + scriptHash); - ElectrumServer.updateSubscribedScriptHashStatus(scriptHash, status); + log.trace("Received script hash status update for non-wallet script hash: " + scriptHash); } else if(status != null && existingStatuses.contains(status)) { log.debug("Received script hash status update, but status has not changed"); return;