refuse an announced tip the header sync cannot link in, reverting to the store tip with a warning
What changed, and why it matters
This commit hardens Sparrow Wallet's handling of block-chain tip announcements from Electrum servers. Previously, a server could announce a fake high block height and the wallet might keep retrying or accept the claim, affecting confirmation counts shown to the user. The change now refuses an announced tip if the wallet cannot verify it links into the known chain, falls back to the last verified store tip, and warns the user. It also prevents endless retries on the same bad tip.
Review the fallback behavior under reorgs and empty stores, ensure the warning UI is prominent enough, and verify that public-server rotation logic correctly surfaces the now-rethrown UnsupportedMethodException to users.
Security signals we found
Mitigates a malicious or buggy Electrum server announcing an unsubstantiated high chain tip
Prevents indefinite retry of a failed/bad tip announcement
Falls back to the locally verified header-store tip instead of the server's claim
Raises UnsupportedMethodException for mandatory servers so the tip is refused rather than silently accepted
Adds unit tests demonstrating refusal of an unlinked tip and a mandatory server missing required headers
Evidence from the diff
ElectrumServer.java adds getStoreTip() and a refuseAnnouncedTip() path in HeaderSyncService. The service now catches VerificationException, UnsupportedMethodException, ServerException and ElectrumServerRpcException during header sync. If a tip cannot be substantiated (bad linkage, missing blockchain.block.headers on a mandatory public server, repeated RPC/server errors), it posts NewBlockEvent back to the verified store tip and warns the user. A failedTip field stops infinite retries. For mandatory verification, UnsupportedMethodException is now rethrown instead of swallowed, so the tip is refused while serverCapability.merkleProofs stays enabled to let wallet history rotation occur. Tests cover an unlinked high tip and a mandatory server lacking blockchain.block.headers.
Changed components
src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.javasrc/test/java/com/sparrowwallet/sparrow/net/HeaderSyncTest.javaHeaderSyncServiceServerCapability / transaction verification logicInspect captured patch +111 / −3
### src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -1663,6 +1663,22 @@ private void reconcile(HeaderStore store, int tipHeight) throws ServerException,
}
}
+ /**
+ * The tip of the verified header store, whose header is null while the store holds nothing above the last pin.
+ */
+ static ChainTip getStoreTip() throws ServerException {
+ HeaderStore store = getHeaderStore();
+ try {
+ //The store's monitor, so that a reorg cannot truncate it between reading the tip height and the header at that height
+ synchronized(store) {
+ int tipHeight = store.getTipHeight();
+ return new ChainTip(tipHeight, store.getHeader(tipHeight));
+ }
+ } catch(IOException e) {
+ throw new ServerException("Could not read the block header store", e);
+ }
+ }
+
/**
* The header at the given height verified against the compiled-in checkpoints, or null where the connected server cannot substantiate it, which is
* reported as a refusal. Heights above the last pin are served from the store, and those below it by hash linkage to a pin.
@@ -3407,17 +3423,57 @@ public static class HeaderSyncService extends ScheduledService<Void> {
//The pair from the event that last restarted this service: the height and the header of one announcement, never of two
private volatile ChainTip announcedTip;
+ //The announcement whose run last failed, so that one still unsubstantiated when its retry fails too is refused rather than retried indefinitely.
+ //Held as the tip rather than a count, since a run cancelled by a later announcement can still fail after it and would spend that one's retry
+ private volatile ChainTip failedTip;
+
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() throws Exception {
- syncAnnouncedHeaders(announcedTip);
+ ChainTip tip = announcedTip;
+ try {
+ syncAnnouncedHeaders(tip);
+ } catch(VerificationException | UnsupportedMethodException e) {
+ refuseAnnouncedTip(tip, e);
+ } catch(ServerException | ElectrumServerRpcException e) {
+ //A failed call says nothing about the chain on its own, but a server answering every request for these headers with an error
+ //has not substantiated the tip any more than one serving the wrong headers
+ if(failedTip != tip || !isConnected()) {
+ failedTip = tip;
+ throw e;
+ }
+
+ refuseAnnouncedTip(tip, e);
+ }
+
return null;
}
};
}
+ /**
+ * Warns of an announced tip the header sync could not substantiate, and sets the chain tip back to the verified store tip where the store holds a
+ * header to set it back to, an empty store leaving only the warning. An announced header need
+ * only meet the target it claims for itself, which at the minimum difficulty costs nothing to produce, so until it links into the chain the height
+ * it arrived with is only the server's claim, as is every confirmation count taken from it.
+ */
+ private void refuseAnnouncedTip(ChainTip tip, Exception e) throws ServerException {
+ ChainTip storeTip = getStoreTip();
+ Platform.runLater(() -> {
+ //A run overtaken by a later announcement has nothing left to correct
+ if(tip != announcedTip) {
+ return;
+ }
+
+ if(storeTip.header() != null) {
+ EventManager.get().post(new NewBlockEvent(storeTip.height(), storeTip.header()));
+ }
+ warnInvalidTip("Could not verify the block header chain to the tip announced at height " + tip.height() + ": " + e.getMessage());
+ });
+ }
+
/**
* The body of a run, which happens on a background thread: the connection check is therefore the transport level one, since AppServices reads
* a JavaFX Service and may only be called from the application thread. Without the check a retry firing after the connection closed would
@@ -3434,8 +3490,9 @@ static void syncAnnouncedHeaders(ChainTip tip) throws ServerException {
} catch(UnsupportedMethodException e) {
//Without this call the store can never advance, so verification would refuse every new confirmation for the rest of the session
if(isVerificationMandatory()) {
- //Leaving the capability on is what lets the next wallet history thread raise this and rotate the server, which this service cannot do
- log.warn("Server does not support " + e.getMethod() + ", which is required to verify transactions");
+ //Leaving the capability on is what lets the next wallet history thread raise this and rotate the server, which this service cannot do.
+ //Rethrown so the tip is refused: where verification is mandatory, a server without the call has not substantiated the height it announced
+ throw e;
} else {
log.warn("Server does not support " + e.getMethod() + ", disabling transaction verification for this session");
serverCapability.withMerkleProofs(false);
### src/test/java/com/sparrowwallet/sparrow/net/HeaderSyncTest.java
@@ -13,6 +13,7 @@
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.event.ChainReorgEvent;
+import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.google.common.eventbus.Subscribe;
import org.junit.jupiter.api.AfterAll;
@@ -38,6 +39,7 @@
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -741,6 +743,55 @@ public void doesNotSyncOnceTheConnectionHasGone() throws Exception {
assertEquals(0, fake.getChunkRequests());
}
+ /**
+ * An announced header need only meet the target it claims for itself, which at the minimum difficulty costs nothing to produce, so a server can
+ * announce a height the chain has not reached. The sync refuses such a tip, and the store tip it leaves behind - holding the honest headers the
+ * server did serve - is the verified height the chain tip is set back to. An empty store has no header to set it back to.
+ */
+ @Test
+ public void refusesAnAnnouncedTipAboveTheChainTheServerServes() throws Exception {
+ List<BlockHeader> chain = mineChain(Network.REGTEST.getGenesisHeader(), 12, CHAIN_TIME);
+ BlockHeader unlinked = mineChain(Network.REGTEST.getGenesisHeader(), 1, BRANCH_TIME).getFirst();
+ assertNull(ElectrumServer.getStoreTip().header());
+
+ seedStore(chain, 10);
+ serve(chain);
+ assertThrows(VerificationException.class, () -> ElectrumServer.HeaderSyncService.syncAnnouncedHeaders(new ChainTip(5000, unlinked)));
+
+ ChainTip storeTip = ElectrumServer.getStoreTip();
+ assertEquals(12, storeTip.height());
+ assertEquals(chain.getLast().getHash(), storeTip.header().getHash());
+ }
+
+ /**
+ * Where verification is mandatory, a server answering that it has no block.headers has not substantiated the tip it announced, so the failure must
+ * reach the service as itself for the tip to be refused - with the capability still on, which is what lets a history pass rotate the server. A
+ * private server answering the same has verification turned off for the session instead, and is never refused.
+ */
+ @Test
+ public void refusesATipFromAMandatoryServerWithoutTheHeadersCall() throws Exception {
+ Network.set(Network.MAINNET);
+ ServerCapability previousCapability = ElectrumServer.serverCapability;
+ ServerType previousServerType = Config.get().getServerType();
+ try {
+ FakeElectrumServerRpc fake = serve(List.of());
+ fake.setFailure(new UnsupportedMethodException("blockchain.block.headers", new IllegalStateException()));
+ ChainTip tip = new ChainTip(Network.MAINNET.getHeaderCheckpoints().getMaxHeight() + 10, Network.MAINNET.getGenesisHeader());
+
+ ElectrumServer.serverCapability = new ServerCapability(false, false, false);
+ Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
+ assertThrows(UnsupportedMethodException.class, () -> ElectrumServer.HeaderSyncService.syncAnnouncedHeaders(tip));
+ assertTrue(ElectrumServer.serverCapability.supportsMerkleProofs());
+
+ Config.get().setServerType(ServerType.ELECTRUM_SERVER);
+ ElectrumServer.HeaderSyncService.syncAnnouncedHeaders(tip);
+ assertFalse(ElectrumServer.serverCapability.supportsMerkleProofs());
+ } finally {
+ Config.get().setServerType(previousServerType);
+ ElectrumServer.serverCapability = previousCapability;
+ }
+ }
+
private static void runOffThread(Callable<Void> task) throws Exception {
ExecutorService executorService = Executors.newSingleThreadExecutor();
try {Why this scored 63/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.