mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-10-06 11:38:25 +00:00
support additional electrum server rpc methods and configurable batch paging
This commit is contained in:
+20
-1
@@ -126,10 +126,29 @@ compileJava {
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform {
|
||||
//Checkpoint verification needs the network and checks compiled in data rather than code, so it is a release checklist step
|
||||
excludeTags 'checkpoint'
|
||||
}
|
||||
jvmArgs = ["--enable-native-access=ALL-UNNAMED"]
|
||||
}
|
||||
|
||||
tasks.register('verifyCheckpoint', Test) {
|
||||
description = 'Verifies the compiled in header checkpoints against live public Electrum servers'
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
includeTags 'checkpoint'
|
||||
}
|
||||
jvmArgs = ["--enable-native-access=ALL-UNNAMED"]
|
||||
outputs.upToDateWhen { false }
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
exceptionFormat = 'full'
|
||||
}
|
||||
}
|
||||
|
||||
application {
|
||||
mainModule = 'com.sparrowwallet.sparrow'
|
||||
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
|
||||
|
||||
+1
-1
Submodule drongo updated: b38561072d...96c91f506e
@@ -5,6 +5,8 @@ import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.VerificationException;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
|
||||
@@ -22,6 +24,7 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
private static final Logger log = LoggerFactory.getLogger(BatchedElectrumServerRpc.class);
|
||||
static final int DEFAULT_MAX_ATTEMPTS = 5;
|
||||
static final int RETRY_DELAY_SECS = 1;
|
||||
static final int HEADERS_BATCH_PAGE_SIZE = 4; //Four difficulty periods of headers is ~1.3MB of hex, within every server's max response size
|
||||
|
||||
private final AtomicLong idCounter;
|
||||
private final int maxTargetBlocks;
|
||||
@@ -287,6 +290,93 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, TransactionMerkleProof> getTransactionMerkleProofs(Transport transport, Wallet wallet, Collection<BlockTransactionHash> references) {
|
||||
PagedBatchRequestBuilder<String, TransactionMerkleProof> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(String.class).returnType(TransactionMerkleProof.class);
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Verifying " + references.size() + " transactions"));
|
||||
|
||||
for(BlockTransactionHash reference : references) {
|
||||
//Keyed by the exact pair: the same txid may legitimately be requested at two heights in one batch, and each must be answered on its own
|
||||
batchRequest.add(reference.getHashAsString() + ":" + reference.getHeight(), "blockchain.transaction.get_merkle", reference.getHashAsString(), reference.getHeight());
|
||||
}
|
||||
|
||||
try {
|
||||
//Every page is sent, per-page errors are substituted with the sentinel, and the merged map covers every requested pair
|
||||
return batchRequest.executeTolerant(DEFAULT_MAX_ATTEMPTS, TransactionMerkleProof.ERROR_PROOF, e -> {
|
||||
//Method not found is a whole batch property: either the server implements get_merkle or it does not
|
||||
if(ElectrumServerRpc.isMethodNotFound(e)) {
|
||||
throw new UnsupportedMethodException("blockchain.transaction.get_merkle", e);
|
||||
}
|
||||
});
|
||||
} catch(UnsupportedMethodException e) {
|
||||
throw e; //before the generic catch, or it is rewrapped and the caller cannot tell an unsupported server from a refusal
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve merkle proofs", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockHeaders getBlockHeadersChunk(Transport transport, int startHeight, int count) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BlockHeaders blockHeaders = new RetryLogic<BlockHeaders>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(BlockHeaders.class).method("blockchain.block.headers").id(idCounter.incrementAndGet()).params(startHeight, count).execute());
|
||||
|
||||
return ElectrumServerRpc.checkBlockHeaders(blockHeaders, startHeight, count);
|
||||
} catch(UnsupportedMethodException | VerificationException e) {
|
||||
throw e; //before the generic catch, so callers can treat an unsupported server and a malformed response on their own terms
|
||||
} catch(JsonRpcException e) {
|
||||
if(ElectrumServerRpc.isMethodNotFound(e)) {
|
||||
throw new UnsupportedMethodException("blockchain.block.headers", e);
|
||||
}
|
||||
|
||||
throw new ElectrumServerRpcException("Failed to retrieve " + count + " block headers from height " + startHeight, e);
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve " + count + " block headers from height " + startHeight, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Integer, BlockHeaders> getBlockHeadersChunks(Transport transport, Map<Integer, Integer> startHeightCounts) {
|
||||
//A page of the default size would be tens of megabytes of hex, far beyond what any server returns in one response
|
||||
PagedBatchRequestBuilder<Integer, BlockHeaders> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(Integer.class).returnType(BlockHeaders.class)
|
||||
.pageSize(HEADERS_BATCH_PAGE_SIZE);
|
||||
|
||||
for(Map.Entry<Integer, Integer> startHeightCount : startHeightCounts.entrySet()) {
|
||||
batchRequest.add(startHeightCount.getKey(), "blockchain.block.headers", startHeightCount.getKey(), startHeightCount.getValue());
|
||||
}
|
||||
|
||||
Map<Integer, BlockHeaders> result;
|
||||
try {
|
||||
result = batchRequest.executeTolerant(DEFAULT_MAX_ATTEMPTS, BlockHeaders.ERROR_HEADERS, e -> {
|
||||
if(ElectrumServerRpc.isMethodNotFound(e)) {
|
||||
throw new UnsupportedMethodException("blockchain.block.headers", e);
|
||||
}
|
||||
});
|
||||
} catch(UnsupportedMethodException e) {
|
||||
throw e;
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve block headers for start heights: " + startHeightCounts.keySet(), e);
|
||||
}
|
||||
|
||||
//A range the server errored on, or whose response fails the checks, is omitted rather than failing the ranges that succeeded
|
||||
Map<Integer, BlockHeaders> checked = new LinkedHashMap<>();
|
||||
for(Map.Entry<Integer, BlockHeaders> entry : result.entrySet()) {
|
||||
if(entry.getValue() == BlockHeaders.ERROR_HEADERS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
checked.put(entry.getKey(), ElectrumServerRpc.checkBlockHeaders(entry.getValue(), entry.getKey(), startHeightCounts.get(entry.getKey())));
|
||||
} catch(VerificationException e) {
|
||||
log.warn("Omitting block headers from height " + entry.getKey() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return checked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks) {
|
||||
PagedBatchRequestBuilder<Integer, Double> batchRequest = PagedBatchRequestBuilder.create(transport, idCounter).keysType(Integer.class).returnType(Double.class);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
/**
|
||||
* The blockchain.block.headers response: a run of consecutive block headers as concatenated hex, with the maximum number of headers the server will return.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class BlockHeaders {
|
||||
public static final int HEADER_HEX_LENGTH = 160;
|
||||
|
||||
/**
|
||||
* Substituted for a range the server returned an error for, and filtered out before a batched result is returned.
|
||||
*/
|
||||
public static final BlockHeaders ERROR_HEADERS = new BlockHeaders();
|
||||
|
||||
public int count;
|
||||
public String hex;
|
||||
public int max;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "BlockHeaders{count=" + count + ", max=" + max + '}';
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import java.util.stream.Stream;
|
||||
public class ElectrumServer {
|
||||
private static final Logger log = LoggerFactory.getLogger(ElectrumServer.class);
|
||||
|
||||
private static final String[] SUPPORTED_VERSIONS = new String[]{"1.3", "1.4.2"};
|
||||
static final String[] SUPPORTED_VERSIONS = new String[]{"1.3", "1.4.2"};
|
||||
|
||||
private static final Version ELECTRS_MIN_BATCHING_VERSION = new Version("0.9.0");
|
||||
|
||||
@@ -1882,7 +1882,7 @@ public class ElectrumServer {
|
||||
}
|
||||
|
||||
if(server.startsWith("cormorant")) {
|
||||
return new ServerCapability(true, false, true, false, true);
|
||||
return new ServerCapability(true, false, true, false, true).withMerkleProofs(false);
|
||||
}
|
||||
|
||||
if(server.startsWith("electrs/")) {
|
||||
@@ -2094,6 +2094,10 @@ public class ElectrumServer {
|
||||
}
|
||||
}
|
||||
|
||||
if(isVerificationMandatory() && !serverCapability.supportsMerkleProofs()) {
|
||||
throw new ServerException("Server does not support transaction verification (blockchain.transaction.get_merkle)");
|
||||
}
|
||||
|
||||
BlockHeaderTip tip;
|
||||
if(subscribe) {
|
||||
tip = electrumServer.subscribeBlockHeaders();
|
||||
@@ -2101,6 +2105,13 @@ public class ElectrumServer {
|
||||
if(tipError != null) {
|
||||
throw new ServerException(tipError);
|
||||
}
|
||||
if(isVerificationMandatory()) {
|
||||
//A server below the last pinned header cannot serve the header sync, and would report every proof as refused
|
||||
int maxCheckpointHeight = Network.get().getHeaderCheckpoints().getMaxHeight();
|
||||
if(tip.height < maxCheckpointHeight) {
|
||||
throw new ServerException("Server is at height " + tip.height + ", below the last verified checkpoint at height " + maxCheckpointHeight);
|
||||
}
|
||||
}
|
||||
initializeTip(tip);
|
||||
subscribedScriptHashes.clear();
|
||||
} else {
|
||||
@@ -2142,6 +2153,10 @@ public class ElectrumServer {
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isVerificationMandatory() {
|
||||
return Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER && Network.get() == Network.MAINNET;
|
||||
}
|
||||
|
||||
private void checkTipStaleness() {
|
||||
if(subscribe && Network.get() == Network.MAINNET && lastTipReceivedAt > 0 && !staleTipWarned && System.currentTimeMillis() - lastTipReceivedAt > STALE_TIP_WARNING_AGE_MILLIS) {
|
||||
staleTipWarned = true;
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
|
||||
import com.sparrowwallet.drongo.protocol.HeaderChainState;
|
||||
import com.sparrowwallet.drongo.protocol.VerificationException;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public interface ElectrumServerRpc {
|
||||
/** The JSON-RPC standard code for a method the server does not implement, which bitcoind returns as well as Electrum servers. */
|
||||
int METHOD_NOT_FOUND = -32601;
|
||||
|
||||
void ping(Transport transport);
|
||||
|
||||
List<String> getServerVersion(Transport transport, String clientName, String[] supportedVersions);
|
||||
@@ -38,6 +48,24 @@ public interface ElectrumServerRpc {
|
||||
|
||||
Map<String, VerboseTransaction> getVerboseTransactions(Transport transport, Set<String> txids, String scriptHash);
|
||||
|
||||
/**
|
||||
* Retrieves the merkle inclusion proof for each of the given transactions at the height it is claimed to be confirmed at.
|
||||
* The result is keyed by the exact pair as "txid:height", since the same transaction may legitimately be requested at two heights in one call,
|
||||
* and carries TransactionMerkleProof.ERROR_PROOF for every pair the server returned an error for.
|
||||
*/
|
||||
Map<String, TransactionMerkleProof> getTransactionMerkleProofs(Transport transport, Wallet wallet, Collection<BlockTransactionHash> references);
|
||||
|
||||
/**
|
||||
* Retrieves a run of consecutive block headers, throwing VerificationException if the response is malformed or short of the server's own tip.
|
||||
*/
|
||||
BlockHeaders getBlockHeadersChunk(Transport transport, int startHeight, int count);
|
||||
|
||||
/**
|
||||
* Retrieves several runs of consecutive block headers, keyed by start height. A range the server errors on, or whose response fails the
|
||||
* checks getBlockHeadersChunk applies, is omitted from the result rather than failing the others.
|
||||
*/
|
||||
Map<Integer, BlockHeaders> getBlockHeadersChunks(Transport transport, Map<Integer, Integer> startHeightCounts);
|
||||
|
||||
Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks);
|
||||
|
||||
Map<Double, Long> getFeeRateHistogram(Transport transport);
|
||||
@@ -47,4 +75,44 @@ public interface ElectrumServerRpc {
|
||||
String broadcastTransaction(Transport transport, String txHex);
|
||||
|
||||
long getIdCounterValue();
|
||||
|
||||
/** Whether every error in a batch reports the method as not found, which is a property of the server rather than of any one request. */
|
||||
static boolean isMethodNotFound(JsonRpcBatchException e) {
|
||||
return !e.getErrors().isEmpty() && e.getErrors().values().stream().allMatch(error -> error.getCode() == METHOD_NOT_FOUND);
|
||||
}
|
||||
|
||||
static boolean isMethodNotFound(JsonRpcException e) {
|
||||
return e.getErrorMessage() != null && e.getErrorMessage().getCode() == METHOD_NOT_FOUND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies Electrum's checks to a blockchain.block.headers response. A server returning at most a partial difficulty period cannot serve the
|
||||
* header sync at all, and a response shorter than requested is only explicable by the server having reached its own announced tip.
|
||||
*/
|
||||
static BlockHeaders checkBlockHeaders(BlockHeaders blockHeaders, int startHeight, int count) throws VerificationException {
|
||||
return checkBlockHeaders(blockHeaders, startHeight, count, AppServices.getCurrentBlockHeight());
|
||||
}
|
||||
|
||||
static BlockHeaders checkBlockHeaders(BlockHeaders blockHeaders, int startHeight, int count, Integer tip) throws VerificationException {
|
||||
if(blockHeaders == null || blockHeaders.hex == null || blockHeaders.count < 0) {
|
||||
throw new VerificationException("Malformed response to a request for " + count + " block headers from height " + startHeight);
|
||||
}
|
||||
if(blockHeaders.max < HeaderChainState.RETARGET_INTERVAL) {
|
||||
throw new VerificationException("Server returns at most " + blockHeaders.max + " block headers per request, too few to cover a difficulty period");
|
||||
}
|
||||
if(blockHeaders.count > count) {
|
||||
throw new VerificationException("Requested " + count + " block headers from height " + startHeight + " but the server returned " + blockHeaders.count);
|
||||
}
|
||||
if(blockHeaders.hex.length() != blockHeaders.count * BlockHeaders.HEADER_HEX_LENGTH) {
|
||||
throw new VerificationException("Response to a request for block headers from height " + startHeight + " contains " + blockHeaders.hex.length() / 2
|
||||
+ " bytes for " + blockHeaders.count + " headers");
|
||||
}
|
||||
|
||||
if(blockHeaders.count < count && tip != null && startHeight + blockHeaders.count - 1 < tip) {
|
||||
throw new VerificationException("Requested " + count + " block headers from height " + startHeight + " but the server returned only " + blockHeaders.count
|
||||
+ " while announcing a tip at height " + tip);
|
||||
}
|
||||
|
||||
return blockHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static com.sparrowwallet.sparrow.net.BatchedElectrumServerRpc.DEFAULT_MAX_ATTEMPTS;
|
||||
import static com.sparrowwallet.sparrow.net.BatchedElectrumServerRpc.RETRY_DELAY_SECS;
|
||||
@@ -40,6 +41,12 @@ public class PagedBatchRequestBuilder<K, V> extends AbstractBuilder {
|
||||
@Nullable
|
||||
private final Class<V> returnType;
|
||||
|
||||
/**
|
||||
* Overrides the configured page size, for methods whose responses are far larger than a typical request
|
||||
*/
|
||||
@Nullable
|
||||
private Integer pageSize;
|
||||
|
||||
/**
|
||||
* Creates a new batch request builder in an initial state
|
||||
*
|
||||
@@ -84,7 +91,9 @@ public class PagedBatchRequestBuilder<K, V> extends AbstractBuilder {
|
||||
* @return a new builder
|
||||
*/
|
||||
public <NK> PagedBatchRequestBuilder<NK, V> keysType(@NotNull Class<NK> keysClass) {
|
||||
return new PagedBatchRequestBuilder<NK, V>(transport, mapper, new ArrayList<Request<NK>>(), keysClass, returnType, counter);
|
||||
PagedBatchRequestBuilder<NK, V> builder = new PagedBatchRequestBuilder<NK, V>(transport, mapper, new ArrayList<Request<NK>>(), keysClass, returnType, counter);
|
||||
builder.pageSize = pageSize;
|
||||
return builder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +105,21 @@ public class PagedBatchRequestBuilder<K, V> extends AbstractBuilder {
|
||||
* @return a new builder
|
||||
*/
|
||||
public <NV> PagedBatchRequestBuilder<K, NV> returnType(@NotNull Class<NV> valuesClass) {
|
||||
return new PagedBatchRequestBuilder<K, NV>(transport, mapper, requests, keysType, valuesClass, counter);
|
||||
PagedBatchRequestBuilder<K, NV> builder = new PagedBatchRequestBuilder<K, NV>(transport, mapper, requests, keysType, valuesClass, counter);
|
||||
builder.pageSize = pageSize;
|
||||
return builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of requests sent in each batch, overriding the configured maximum page size.
|
||||
*
|
||||
* @param pageSize number of requests per batch
|
||||
* @return the current builder
|
||||
*/
|
||||
@NotNull
|
||||
public PagedBatchRequestBuilder<K, V> pageSize(int pageSize) {
|
||||
this.pageSize = pageSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Map<K, V> execute() throws Exception {
|
||||
@@ -114,54 +137,94 @@ public class PagedBatchRequestBuilder<K, V> extends AbstractBuilder {
|
||||
Map<K, V> allResults = new HashMap<>();
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
|
||||
List<List<Request<K>>> pages = Lists.partition(requests, getPageSize());
|
||||
for(List<Request<K>> page : pages) {
|
||||
if(counter != null) {
|
||||
Map<Long, K> counterIdMap = new HashMap<>();
|
||||
BatchRequestBuilder<Long, V> batchRequest = client.createBatchRequest().keysType(Long.class).returnType(returnType);
|
||||
for(Request<K> request : page) {
|
||||
counterIdMap.put(request.counterId, request.id);
|
||||
batchRequest.add(request.counterId, request.method, request.params);
|
||||
}
|
||||
for(List<Request<K>> page : Lists.partition(requests, getPageSize())) {
|
||||
allResults.putAll(executePage(client, page, maxAttempts));
|
||||
}
|
||||
|
||||
try {
|
||||
Map<Long, V> pageResult = new RetryLogic<Map<Long, V>>(maxAttempts, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
|
||||
for(Map.Entry<Long, V> pageEntry : pageResult.entrySet()) {
|
||||
allResults.put(counterIdMap.get(pageEntry.getKey()), pageEntry.getValue());
|
||||
}
|
||||
} catch(JsonRpcBatchException e) {
|
||||
Map<Object, Object> mappedSuccesess = new HashMap<>();
|
||||
for(Map.Entry<?, ?> successEntry : e.getSuccesses().entrySet()) {
|
||||
mappedSuccesess.put(counterIdMap.get((Long)successEntry.getKey()), successEntry.getValue());
|
||||
}
|
||||
Map<Object, ErrorMessage> mappedErrors = new HashMap<>();
|
||||
for(Map.Entry<?, ErrorMessage> errorEntry : e.getErrors().entrySet()) {
|
||||
mappedErrors.put(counterIdMap.get((Long)errorEntry.getKey()), errorEntry.getValue());
|
||||
}
|
||||
throw new JsonRpcBatchException(e.getMessage(), mappedSuccesess, mappedErrors);
|
||||
}
|
||||
} else {
|
||||
BatchRequestBuilder<K, V> batchRequest = client.createBatchRequest().keysType(keysType).returnType(returnType);
|
||||
for(Request<K> request : page) {
|
||||
if(request.id instanceof String strReq) {
|
||||
batchRequest.add(strReq, request.method, request.params);
|
||||
} else if(request.id instanceof Integer intReq) {
|
||||
batchRequest.add(intReq, request.method, request.params);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Id of class " + request.id.getClass().getName() + " not supported");
|
||||
}
|
||||
}
|
||||
return allResults;
|
||||
}
|
||||
|
||||
Map<K, V> pageResult = new RetryLogic<Map<K, V>>(maxAttempts, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
|
||||
allResults.putAll(pageResult);
|
||||
/**
|
||||
* Validates and executes the request, substituting the given value for the keys any page reports an error for so that the returned map
|
||||
* covers every requested key. Unlike execute(), a JSON-RPC error in one page neither discards the results of earlier pages nor prevents
|
||||
* later pages from being sent, which is what makes a per-key error meaningful when the request spans more than one page.
|
||||
*
|
||||
* @param maxAttempts number of times to try each page
|
||||
* @param errorValue the value to record for keys the server returned an error for
|
||||
* @param batchErrorCheck applied to each page's errors before they are substituted, to let the caller classify whole-batch failures
|
||||
* @return map of responses by request ids, covering every requested id
|
||||
*/
|
||||
@NotNull
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<K, V> executeTolerant(int maxAttempts, V errorValue, Consumer<JsonRpcBatchException> batchErrorCheck) throws Exception {
|
||||
Map<K, V> allResults = new HashMap<>();
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
|
||||
for(List<Request<K>> page : Lists.partition(requests, getPageSize())) {
|
||||
try {
|
||||
allResults.putAll(executePage(client, page, maxAttempts));
|
||||
} catch(JsonRpcBatchException e) {
|
||||
batchErrorCheck.accept(e);
|
||||
allResults.putAll((Map<K, V>)e.getSuccesses());
|
||||
for(Object key : e.getErrors().keySet()) {
|
||||
allResults.put((K)key, errorValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single page, mapping any batch exception back to the request ids the caller supplied
|
||||
*/
|
||||
@NotNull
|
||||
private Map<K, V> executePage(JsonRpcClient client, List<Request<K>> page, int maxAttempts) throws Exception {
|
||||
if(counter != null) {
|
||||
Map<Long, K> counterIdMap = new HashMap<>();
|
||||
BatchRequestBuilder<Long, V> batchRequest = client.createBatchRequest().keysType(Long.class).returnType(returnType);
|
||||
for(Request<K> request : page) {
|
||||
counterIdMap.put(request.counterId, request.id);
|
||||
batchRequest.add(request.counterId, request.method, request.params);
|
||||
}
|
||||
|
||||
try {
|
||||
Map<Long, V> pageResult = new RetryLogic<Map<Long, V>>(maxAttempts, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
|
||||
Map<K, V> mappedResult = new HashMap<>();
|
||||
for(Map.Entry<Long, V> pageEntry : pageResult.entrySet()) {
|
||||
mappedResult.put(counterIdMap.get(pageEntry.getKey()), pageEntry.getValue());
|
||||
}
|
||||
|
||||
return mappedResult;
|
||||
} catch(JsonRpcBatchException e) {
|
||||
Map<Object, Object> mappedSuccesess = new HashMap<>();
|
||||
for(Map.Entry<?, ?> successEntry : e.getSuccesses().entrySet()) {
|
||||
mappedSuccesess.put(counterIdMap.get((Long)successEntry.getKey()), successEntry.getValue());
|
||||
}
|
||||
Map<Object, ErrorMessage> mappedErrors = new HashMap<>();
|
||||
for(Map.Entry<?, ErrorMessage> errorEntry : e.getErrors().entrySet()) {
|
||||
mappedErrors.put(counterIdMap.get((Long)errorEntry.getKey()), errorEntry.getValue());
|
||||
}
|
||||
throw new JsonRpcBatchException(e.getMessage(), mappedSuccesess, mappedErrors);
|
||||
}
|
||||
} else {
|
||||
BatchRequestBuilder<K, V> batchRequest = client.createBatchRequest().keysType(keysType).returnType(returnType);
|
||||
for(Request<K> request : page) {
|
||||
if(request.id instanceof String strReq) {
|
||||
batchRequest.add(strReq, request.method, request.params);
|
||||
} else if(request.id instanceof Integer intReq) {
|
||||
batchRequest.add(intReq, request.method, request.params);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Id of class " + request.id.getClass().getName() + " not supported");
|
||||
}
|
||||
}
|
||||
|
||||
return new RetryLogic<Map<K, V>>(maxAttempts, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(batchRequest::execute);
|
||||
}
|
||||
}
|
||||
|
||||
private int getPageSize() {
|
||||
int pageSize = Config.get().getMaxPageSize();
|
||||
int pageSize = this.pageSize != null ? this.pageSize : Config.get().getMaxPageSize();
|
||||
if(pageSize < 1) {
|
||||
pageSize = DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ServerCapability {
|
||||
private final boolean supportsBlockStats;
|
||||
private final boolean supportsUnsubscribe;
|
||||
private final boolean supportsServerFeatures;
|
||||
private boolean supportsMerkleProofs = true;
|
||||
private List<Integer> supportedSilentPaymentsVersions = Collections.emptyList();
|
||||
|
||||
public ServerCapability(boolean supportsBatching, boolean supportsUnsubscribe, boolean supportsServerFeatures) {
|
||||
@@ -64,6 +65,10 @@ public class ServerCapability {
|
||||
return supportsServerFeatures;
|
||||
}
|
||||
|
||||
public boolean supportsMerkleProofs() {
|
||||
return supportsMerkleProofs;
|
||||
}
|
||||
|
||||
public List<Integer> getSupportedSilentPaymentsVersions() {
|
||||
return supportedSilentPaymentsVersions;
|
||||
}
|
||||
@@ -72,6 +77,11 @@ public class ServerCapability {
|
||||
return supportedSilentPaymentsVersions.contains(0);
|
||||
}
|
||||
|
||||
public ServerCapability withMerkleProofs(boolean supportsMerkleProofs) {
|
||||
this.supportsMerkleProofs = supportsMerkleProofs;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ServerCapability withServerFeatures(ServerFeatures features) {
|
||||
if(features != null && features.silent_payments != null) {
|
||||
this.supportedSilentPaymentsVersions = List.copyOf(features.silent_payments);
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.VerificationException;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
@@ -325,6 +327,76 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, TransactionMerkleProof> getTransactionMerkleProofs(Transport transport, Wallet wallet, Collection<BlockTransactionHash> references) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
|
||||
Map<String, TransactionMerkleProof> result = new LinkedHashMap<>();
|
||||
for(BlockTransactionHash reference : references) {
|
||||
EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Verifying transaction [" + reference.getHashAsString().substring(0, 6) + "]"));
|
||||
//Keyed by the exact pair: the same txid may legitimately be requested at two heights, and each must be answered on its own
|
||||
String key = reference.getHashAsString() + ":" + reference.getHeight();
|
||||
try {
|
||||
TransactionMerkleProof proof = new RetryLogic<TransactionMerkleProof>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(TransactionMerkleProof.class).method("blockchain.transaction.get_merkle").id(idCounter.incrementAndGet())
|
||||
.params(reference.getHashAsString(), reference.getHeight()).execute());
|
||||
result.put(key, proof);
|
||||
} catch(ServerException e) {
|
||||
//If there is an error with the server connection, don't keep trying - this may take too long given many transactions
|
||||
throw new ElectrumServerRpcException("Failed to retrieve merkle proof for transaction [" + reference.getHashAsString().substring(0, 6) + "]", e);
|
||||
} catch(JsonRpcException e) {
|
||||
//Method not found is a property of the server rather than of this transaction, so stop rather than recording a refusal for every one
|
||||
if(ElectrumServerRpc.isMethodNotFound(e)) {
|
||||
throw new UnsupportedMethodException("blockchain.transaction.get_merkle", e);
|
||||
}
|
||||
|
||||
result.put(key, TransactionMerkleProof.ERROR_PROOF);
|
||||
} catch(Exception e) {
|
||||
result.put(key, TransactionMerkleProof.ERROR_PROOF);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockHeaders getBlockHeadersChunk(Transport transport, int startHeight, int count) {
|
||||
try {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
BlockHeaders blockHeaders = new RetryLogic<BlockHeaders>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
|
||||
client.createRequest().returnAs(BlockHeaders.class).method("blockchain.block.headers").id(idCounter.incrementAndGet()).params(startHeight, count).execute());
|
||||
|
||||
return ElectrumServerRpc.checkBlockHeaders(blockHeaders, startHeight, count);
|
||||
} catch(UnsupportedMethodException | VerificationException e) {
|
||||
throw e; //before the generic catch, so callers can treat an unsupported server and a malformed response on their own terms
|
||||
} catch(JsonRpcException e) {
|
||||
if(ElectrumServerRpc.isMethodNotFound(e)) {
|
||||
throw new UnsupportedMethodException("blockchain.block.headers", e);
|
||||
}
|
||||
|
||||
throw new ElectrumServerRpcException("Failed to retrieve " + count + " block headers from height " + startHeight, e);
|
||||
} catch(Exception e) {
|
||||
throw new ElectrumServerRpcException("Failed to retrieve " + count + " block headers from height " + startHeight, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Integer, BlockHeaders> getBlockHeadersChunks(Transport transport, Map<Integer, Integer> startHeightCounts) {
|
||||
Map<Integer, BlockHeaders> result = new LinkedHashMap<>();
|
||||
for(Map.Entry<Integer, Integer> startHeightCount : startHeightCounts.entrySet()) {
|
||||
try {
|
||||
result.put(startHeightCount.getKey(), getBlockHeadersChunk(transport, startHeightCount.getKey(), startHeightCount.getValue()));
|
||||
} catch(UnsupportedMethodException e) {
|
||||
throw e;
|
||||
} catch(VerificationException | ElectrumServerRpcException e) {
|
||||
//A range that fails is omitted rather than failing the ranges that succeeded
|
||||
log.warn("Omitting block headers from height " + startHeightCount.getKey() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<Integer, Double> getFeeEstimates(Transport transport, List<Integer> targetBlocks) {
|
||||
JsonRpcClient client = new JsonRpcClient(transport);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The blockchain.transaction.get_merkle response: the sibling path from a transaction to the merkle root of the block containing it.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TransactionMerkleProof {
|
||||
/**
|
||||
* Substituted for a transaction the server returned an error for, so that a batch covering many transactions can partially succeed.
|
||||
*/
|
||||
public static final TransactionMerkleProof ERROR_PROOF = new TransactionMerkleProof();
|
||||
|
||||
public int block_height;
|
||||
public List<String> merkle;
|
||||
public int pos;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TransactionMerkleProof{block_height=" + block_height + ", pos=" + pos + ", merkle=" + merkle + '}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
/**
|
||||
* Thrown when the connected server does not implement a method, as reported by the JSON-RPC method not found error code.
|
||||
* Unlike other errors this is a property of the server rather than of the request, so callers disable the feature for the session
|
||||
* rather than treating the response as a refusal.
|
||||
*/
|
||||
public class UnsupportedMethodException extends ElectrumServerRpcException {
|
||||
private final String method;
|
||||
|
||||
public UnsupportedMethodException(String method, Throwable cause) {
|
||||
super("Server does not support " + method, cause);
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.net.Bwt;
|
||||
import com.sparrowwallet.sparrow.net.ConfigurationException;
|
||||
import com.sparrowwallet.sparrow.net.CoreAuthType;
|
||||
import com.sparrowwallet.sparrow.net.ElectrumServerRpc;
|
||||
import com.sparrowwallet.sparrow.net.cormorant.Cormorant;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
@@ -51,7 +52,6 @@ public class BitcoindClient {
|
||||
private static final long PRUNED_RESCAN_TIMEGAP_MILLIS = 7200*1000;
|
||||
|
||||
//Error codes from https://github.com/bitcoin/bitcoin/blob/master/src/rpc/protocol.h
|
||||
public static final int RPC_METHOD_NOT_FOUND = -32601;
|
||||
public static final int RPC_WALLET_NOT_FOUND = -18;
|
||||
|
||||
public static final String WALLET_ALREADY_LOADING_MESSAGE = "Wallet already loading.";
|
||||
@@ -159,7 +159,7 @@ public class BitcoindClient {
|
||||
}
|
||||
legacyWalletExists = loadedWallets.contains(Bwt.DEFAULT_CORE_WALLET);
|
||||
} catch(JsonRpcException e) {
|
||||
if(e.getErrorMessage().getCode() == RPC_METHOD_NOT_FOUND) {
|
||||
if(ElectrumServerRpc.isMethodNotFound(e)) {
|
||||
throw new BitcoinRPCException("Wallet support must be enabled in Bitcoin Core");
|
||||
} else {
|
||||
throw e;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.HeaderChainState;
|
||||
import com.sparrowwallet.drongo.protocol.VerificationException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* The checks Electrum applies to a blockchain.block.headers response. Every failure is refusal class rather than a session failure, so the
|
||||
* header sync stops advancing on a server answering nonsense instead of feeding it to the chain state.
|
||||
*/
|
||||
public class ElectrumServerRpcTest {
|
||||
private static final int TIP = 900000;
|
||||
|
||||
@Test
|
||||
public void acceptsAFullResponse() {
|
||||
assertDoesNotThrow(() -> ElectrumServerRpc.checkBlockHeaders(headers(2016, 2016, 2016), 800000, 2016, TIP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsAShortResponseThatReachesTheTip() {
|
||||
//The only reason a server may send fewer headers than asked for
|
||||
assertDoesNotThrow(() -> ElectrumServerRpc.checkBlockHeaders(headers(17, 17, 2016), TIP - 16, 2016, TIP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAShortResponseBelowTheTip() {
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(17, 17, 2016), 800000, 2016, TIP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsAShortResponseWhenTheTipIsUnknown() {
|
||||
//Before the application has a chain view there is nothing to judge the response against, so the caller's own linkage check decides
|
||||
assertDoesNotThrow(() -> ElectrumServerRpc.checkBlockHeaders(headers(17, 17, 2016), 800000, 2016, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMoreHeadersThanRequested() {
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(2017, 2017, 2016), 800000, 2016, TIP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAHexLengthInconsistentWithTheCount() {
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(2016, 2015, 2016), 800000, 2016, TIP));
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(2016, 2017, 2016), 800000, 2016, TIP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAServerThatCannotReturnAWholeDifficultyPeriod() {
|
||||
//A period must always fit one call, or the sync cannot advance across a retarget
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(2016, 2016, HeaderChainState.RETARGET_INTERVAL - 1), 800000, 2016, TIP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedResponses() {
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(null, 800000, 2016, TIP));
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(2016, -1, 2016), 800000, 2016, TIP));
|
||||
assertThrows(VerificationException.class, () -> ElectrumServerRpc.checkBlockHeaders(headers(-1, 0, 2016), 800000, 2016, TIP));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param count the count the server reports
|
||||
* @param hexHeaders the number of headers actually present in the hex
|
||||
* @param max the maximum number of headers the server reports it will return
|
||||
*/
|
||||
private static BlockHeaders headers(int count, int hexHeaders, int max) {
|
||||
BlockHeaders blockHeaders = new BlockHeaders();
|
||||
blockHeaders.count = count;
|
||||
blockHeaders.hex = hexHeaders < 0 ? null : "0".repeat(hexHeaders * BlockHeaders.HEADER_HEX_LENGTH);
|
||||
blockHeaders.max = max;
|
||||
|
||||
return blockHeaders;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.BlockHeader;
|
||||
import com.sparrowwallet.drongo.protocol.HeaderChainState;
|
||||
import com.sparrowwallet.drongo.protocol.HeaderCheckpoints;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies the pinned header hashes compiled into drongo against live public Electrum servers. This is a release checklist step rather than a unit
|
||||
* test - it needs the network and it is checking data, not code - so it is tagged out of the default test task and run with the verifyCheckpoint task.
|
||||
* <p>
|
||||
* A server that cannot be reached is skipped; a server that answers with a different chain fails the run. Checkpoint generation is a derivation from a
|
||||
* locally validated header chain, so this confirms independently that what was derived matches what the network says, and that the last pin is buried.
|
||||
*/
|
||||
@Tag("checkpoint")
|
||||
public class HeaderCheckpointVerificationTest {
|
||||
/**
|
||||
* The number of blocks the last pinned header must be buried by. A pin that is later orphaned would have every install refuse the real chain at
|
||||
* that height, so a period is only pinned once it is beyond any plausible reorg.
|
||||
*/
|
||||
private static final int CHECKPOINT_BURIAL_DEPTH = 6;
|
||||
|
||||
private static final int PINS_VERIFIED = 3;
|
||||
private static final int MINIMUM_RESPONDERS = 3;
|
||||
|
||||
@TempDir
|
||||
private static Path tempHome;
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
//Config.get() caches its instance statically for the life of the JVM, so keep this test from loading the developer's real config
|
||||
System.setProperty(SparrowWallet.APP_HOME_PROPERTY, tempHome.toString());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDownAll() {
|
||||
System.clearProperty(SparrowWallet.APP_HOME_PROPERTY);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyMainnetCheckpoints() {
|
||||
verifyCheckpoints(Network.MAINNET);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyTestnetCheckpoints() {
|
||||
verifyCheckpoints(Network.TESTNET);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyTestnet4Checkpoints() {
|
||||
verifyCheckpoints(Network.TESTNET4);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifySignetCheckpoints() {
|
||||
verifyCheckpoints(Network.SIGNET);
|
||||
}
|
||||
|
||||
private void verifyCheckpoints(Network network) {
|
||||
Network.set(network);
|
||||
|
||||
HeaderCheckpoints checkpoints = network.getHeaderCheckpoints();
|
||||
List<PublicElectrumServer> servers = PublicElectrumServer.getServers();
|
||||
assertTrue(!servers.isEmpty(), "No public servers configured for " + network);
|
||||
|
||||
List<String> unreachable = new ArrayList<>();
|
||||
int responders = 0;
|
||||
for(PublicElectrumServer publicServer : servers) {
|
||||
try {
|
||||
verifyAgainstServer(publicServer, checkpoints);
|
||||
responders++;
|
||||
} catch(AssertionError e) {
|
||||
throw new AssertionError(publicServer.getServer().getHostAndPort() + " disagrees with the " + network + " checkpoints: " + e.getMessage(), e);
|
||||
} catch(Exception e) {
|
||||
unreachable.add(publicServer.getServer().getHostAndPort() + " (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
int required = Math.min(MINIMUM_RESPONDERS, servers.size());
|
||||
assertTrue(responders >= required, "Only " + responders + " of " + servers.size() + " " + network + " servers responded, at least " + required
|
||||
+ " are needed to confirm the checkpoints. Unreachable: " + unreachable);
|
||||
}
|
||||
|
||||
private void verifyAgainstServer(PublicElectrumServer publicServer, HeaderCheckpoints checkpoints) throws Exception {
|
||||
try(CloseableTransport transport = publicServer.getServer().getProtocol().getTransport(publicServer.getServer().getHostAndPort())) {
|
||||
transport.connect();
|
||||
Thread reader = new Thread(() -> {
|
||||
try {
|
||||
((TcpTransport)transport).readInputLoop();
|
||||
} catch(ServerException e) {
|
||||
//Expected once the transport is closed
|
||||
}
|
||||
}, "CheckpointVerificationReadThread");
|
||||
reader.setDaemon(true);
|
||||
reader.start();
|
||||
|
||||
ElectrumServerRpc rpc = new SimpleElectrumServerRpc();
|
||||
rpc.getServerVersion(transport, "Sparrow", ElectrumServer.SUPPORTED_VERSIONS);
|
||||
|
||||
//The last pin must be buried, which is established by the server having the headers above it rather than by its announced tip
|
||||
int maxHeight = checkpoints.getMaxHeight();
|
||||
BlockHeaders buried = rpc.getBlockHeadersChunk(transport, maxHeight, CHECKPOINT_BURIAL_DEPTH + 1);
|
||||
assertEquals(CHECKPOINT_BURIAL_DEPTH + 1, buried.count, "The last pinned header at height " + maxHeight + " is not buried by " + CHECKPOINT_BURIAL_DEPTH + " blocks");
|
||||
|
||||
for(int i = 0; i < PINS_VERIFIED; i++) {
|
||||
int pinnedHeight = maxHeight - (i * HeaderChainState.RETARGET_INTERVAL);
|
||||
if(pinnedHeight <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
BlockHeaders pair = rpc.getBlockHeadersChunk(transport, pinnedHeight, 2);
|
||||
assertEquals(2, pair.count, "Server did not return the pinned header at height " + pinnedHeight + " and the one above it");
|
||||
BlockHeader pinned = header(pair, 0);
|
||||
BlockHeader above = header(pair, 1);
|
||||
|
||||
assertTrue(pinned.verifyProofOfWork(), "Header at height " + pinnedHeight + " does not meet its claimed proof of work target");
|
||||
assertTrue(above.verifyProofOfWork(), "Header at height " + (pinnedHeight + 1) + " does not meet its claimed proof of work target");
|
||||
assertEquals(pinned.getHash(), above.getPrevBlockHash(), "Header at height " + (pinnedHeight + 1) + " does not link to the header below it");
|
||||
assertEquals(checkpoints.getHash(pinnedHeight), pinned.getHash(), "Pinned hash at height " + pinnedHeight);
|
||||
assertEquals(checkpoints.getBitsAfter(pinnedHeight), above.getDifficultyTarget(), "Pinned target following height " + pinnedHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BlockHeader header(BlockHeaders blockHeaders, int index) {
|
||||
return new BlockHeader(Utils.hexToBytes(blockHeaders.hex.substring(index * BlockHeaders.HEADER_HEX_LENGTH, (index + 1) * BlockHeaders.HEADER_HEX_LENGTH)));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() {
|
||||
Network.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcBatchException;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Regression tests for cross page partial success. The existing execute() rethrows a batch exception carrying only the failing page's results, so
|
||||
* earlier pages are discarded and later pages are never sent. Merkle proof verification reads a per transaction error as a refusal by that server,
|
||||
* so with execute() a single genuinely refused proof in a multi page request would have been reported as a refusal of every other transaction too.
|
||||
*/
|
||||
public class PagedBatchRequestBuilderTest {
|
||||
@TempDir
|
||||
private static Path tempHome;
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@BeforeAll
|
||||
public static void setUp() {
|
||||
//Config.get() caches its instance statically for the life of the JVM, so keep these tests from loading the developer's real config
|
||||
System.setProperty(SparrowWallet.APP_HOME_PROPERTY, tempHome.toString());
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
public static void tearDown() {
|
||||
System.clearProperty(SparrowWallet.APP_HOME_PROPERTY);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tolerantExecutionCoversEveryKeyAcrossPages() throws Exception {
|
||||
//Six requests over three pages, with one error in the first page and one in the last
|
||||
FakeBatchTransport transport = new FakeBatchTransport(Set.of("b", "f"), 0);
|
||||
Map<String, String> result = builder(transport).executeTolerant(1, "ERROR", e -> {});
|
||||
|
||||
assertEquals(Set.of("a", "b", "c", "d", "e", "f"), result.keySet());
|
||||
assertEquals("result:a", result.get("a"));
|
||||
assertEquals("ERROR", result.get("b"));
|
||||
assertEquals("result:c", result.get("c"));
|
||||
assertEquals("result:e", result.get("e"));
|
||||
assertEquals("ERROR", result.get("f"));
|
||||
assertEquals(3, transport.requests.size(), "Every page must be sent, including those after a page that errored");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tolerantExecutionKeepsEveryResultWhenNothingFails() throws Exception {
|
||||
FakeBatchTransport transport = new FakeBatchTransport(Set.of(), 0);
|
||||
Map<String, String> result = builder(transport).executeTolerant(1, "ERROR", e -> {});
|
||||
|
||||
assertEquals(6, result.size());
|
||||
assertFalse(result.containsValue("ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingExecutionDiscardsResultsOutsideTheFailingPage() {
|
||||
//The behaviour executeTolerant exists to avoid, asserted so that a change to either is a deliberate one
|
||||
FakeBatchTransport transport = new FakeBatchTransport(Set.of("b"), 0);
|
||||
JsonRpcBatchException e = assertThrows(JsonRpcBatchException.class, () -> builder(transport).execute(1));
|
||||
|
||||
assertEquals(Set.of("a"), new HashSet<>(e.getSuccesses().keySet()));
|
||||
assertEquals(1, transport.requests.size(), "Pages after the failing one are never sent");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void batchErrorCheckSeesEveryFailingPage() throws Exception {
|
||||
FakeBatchTransport transport = new FakeBatchTransport(Set.of("b", "f"), 0);
|
||||
List<JsonRpcBatchException> seen = new ArrayList<>();
|
||||
builder(transport).executeTolerant(1, "ERROR", seen::add);
|
||||
|
||||
assertEquals(2, seen.size());
|
||||
assertEquals(Set.of("b"), new HashSet<>(seen.get(0).getErrors().keySet()));
|
||||
assertEquals(Set.of("f"), new HashSet<>(seen.get(1).getErrors().keySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void batchErrorCheckCanAbortTheWholeRequest() {
|
||||
//A method the server does not implement is a property of the server, so the caller stops rather than recording an error per key
|
||||
FakeBatchTransport transport = new FakeBatchTransport(Set.of("b"), ElectrumServerRpc.METHOD_NOT_FOUND);
|
||||
UnsupportedMethodException e = assertThrows(UnsupportedMethodException.class, () -> builder(transport).executeTolerant(1, "ERROR", batchException -> {
|
||||
if(ElectrumServerRpc.isMethodNotFound(batchException)) {
|
||||
throw new UnsupportedMethodException("test.method", batchException);
|
||||
}
|
||||
}));
|
||||
|
||||
assertEquals("test.method", e.getMethod());
|
||||
assertEquals(1, transport.requests.size(), "No further pages are sent once the method is known to be unsupported");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void methodNotFoundRequiresEveryErrorToReportIt() {
|
||||
assertTrue(ElectrumServerRpc.isMethodNotFound(batchException(Map.of("a", ElectrumServerRpc.METHOD_NOT_FOUND, "b", ElectrumServerRpc.METHOD_NOT_FOUND))));
|
||||
assertFalse(ElectrumServerRpc.isMethodNotFound(batchException(Map.of("a", ElectrumServerRpc.METHOD_NOT_FOUND, "b", -32603))));
|
||||
assertFalse(ElectrumServerRpc.isMethodNotFound(batchException(Map.of("a", -32603))));
|
||||
//An empty error map would make allMatch vacuously true, so it is excluded
|
||||
assertFalse(ElectrumServerRpc.isMethodNotFound(batchException(Map.of())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pageSizeSurvivesTheBuilderCopies() throws Exception {
|
||||
FakeBatchTransport transport = new FakeBatchTransport(Set.of(), 0);
|
||||
PagedBatchRequestBuilder<String, String> batchRequest =
|
||||
(PagedBatchRequestBuilder<String, String>)PagedBatchRequestBuilder.create(transport, new AtomicLong()).pageSize(3).keysType(String.class).returnType(String.class);
|
||||
for(String id : List.of("a", "b", "c", "d", "e", "f")) {
|
||||
batchRequest.add(id, "test.method", id);
|
||||
}
|
||||
batchRequest.executeTolerant(1, "ERROR", e -> {});
|
||||
|
||||
assertEquals(2, transport.requests.size(), "pageSize set before keysType and returnType must be carried onto the copies they return");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private PagedBatchRequestBuilder<String, String> builder(Transport transport) {
|
||||
PagedBatchRequestBuilder<String, String> batchRequest =
|
||||
(PagedBatchRequestBuilder<String, String>)PagedBatchRequestBuilder.create(transport, new AtomicLong()).keysType(String.class).returnType(String.class).pageSize(2);
|
||||
for(String id : List.of("a", "b", "c", "d", "e", "f")) {
|
||||
batchRequest.add(id, "test.method", id);
|
||||
}
|
||||
|
||||
return batchRequest;
|
||||
}
|
||||
|
||||
private static JsonRpcBatchException batchException(Map<String, Integer> errorCodes) {
|
||||
Map<Object, com.github.arteam.simplejsonrpc.core.domain.ErrorMessage> errors = new java.util.HashMap<>();
|
||||
errorCodes.forEach((key, code) -> errors.put(key, new com.github.arteam.simplejsonrpc.core.domain.ErrorMessage(code, "error", null)));
|
||||
|
||||
return new JsonRpcBatchException("test", Map.of(), errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a JSON-RPC batch request with a result for every request, except those whose single parameter is named as failing.
|
||||
*/
|
||||
private static class FakeBatchTransport implements Transport {
|
||||
private final Set<String> failingParams;
|
||||
private final int errorCode;
|
||||
private final List<String> requests = new ArrayList<>();
|
||||
|
||||
FakeBatchTransport(Set<String> failingParams, int errorCode) {
|
||||
this.failingParams = failingParams;
|
||||
this.errorCode = errorCode == 0 ? -32603 : errorCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String pass(String request) throws IOException {
|
||||
requests.add(request);
|
||||
ArrayNode responses = MAPPER.createArrayNode();
|
||||
for(JsonNode node : MAPPER.readTree(request)) {
|
||||
String param = node.get("params").get(0).asText();
|
||||
ObjectNode response = responses.addObject();
|
||||
response.put("jsonrpc", "2.0");
|
||||
response.set("id", node.get("id"));
|
||||
if(failingParams.contains(param)) {
|
||||
ObjectNode error = response.putObject("error");
|
||||
error.put("code", errorCode);
|
||||
error.put("message", "failed for " + param);
|
||||
} else {
|
||||
response.put("result", "result:" + param);
|
||||
}
|
||||
}
|
||||
|
||||
return MAPPER.writeValueAsString(responses);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user