refuse an announced tip below the last pinned header
What changed, and why it matters
This commit hardens Sparrow Wallet against a malicious or misbehaving Electrum server that tries to 'rewind' the blockchain tip to a height below the last built-in checkpoint after it has already announced a higher tip. Previously, such a fake low announcement could have been accepted for public servers that are required to verify transactions, potentially letting the server hide or rewrite recent history. The fix refuses those regressive announcements and adds tests to confirm the behavior.
Review the change for completeness and ensure the new `tipReachedCheckpoints` flag is correctly reset in all connection/reconnection paths. Consider whether the same regression check should apply to Bitcoin Core backends or other server types, as the commit explicitly excludes them. No immediate user action is required beyond upgrading to a release containing this commit.
Security signals we found
Hardening against server-side blockchain tip regression below pinned checkpoints
Mandatory verification for public Electrum servers regardless of announced tip height
Session-scoped state to distinguish a server catching up from a server rewinding history
New unit tests covering regression, catch-up, and server-switching scenarios
Evidence from the diff
The patch introduces tipReachedCheckpoints, a session flag set once a server announces a tip at or above Network.get().getHeaderCheckpoints().getMaxHeight(). A new validation method getAnnouncedTipValidationError() wraps the existing connect-time tip checks and additionally returns an error if tipReachedCheckpoints is true and a newly announced tip is below the max checkpoint height. SubscriptionService.newBlockHeaderTip() now uses this stricter validator. isVerifyingTransactions() is also updated so that when isVerificationMandatory() is true (public Electrum servers), verification is always required regardless of the announced tip height, closing the lagging-server accommodation for that tier. The flag is reset on each new connection so a fresh private server catching up is not penalized for its predecessor’s height.
Changed components
src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.javasrc/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.javasrc/test/java/com/sparrowwallet/sparrow/net/ElectrumServerTest.javaInspect captured patch +129 / −6
### src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -129,6 +129,9 @@ public class ElectrumServer {
private static volatile boolean invalidTipWarned;
+ //Whether the connected server has announced a tip at or above the last pinned header, which the chain cannot then rewind below
+ static volatile boolean tipReachedCheckpoints;
+
private static volatile long lastTipWarningLoggedAt;
//A server refusing for capacity recovers within these attempts, one that cannot substantiate a height never does. Not final so tests need not wait
@@ -1678,8 +1681,9 @@ static List<BlockHeader> getLinkedHeaders(BlockHeaders chunk, int count, Sha256H
* setting is asked here so one answer covers the sync, both write boundaries and the connect time enforcement.
* <p>
* A server below the last pinned header cannot substantiate any height, so asking would refuse every new confirmation and raise a dialog for it.
- * The public tier rejects such a server at connect; a private one still catching up simply goes unverified until it arrives. A tip not yet
- * announced is not evidence of lagging.
+ * That accommodation is for a private server still catching up, and is not offered where verification is mandatory: such a server was already
+ * above the last pin when it was accepted at connect, so a later claim to be below it is not a server catching up, and its confirmations belong
+ * in the wallet no more than any other the server cannot prove. A tip not yet announced is not evidence of lagging.
* <p>
* Not asked of a Bitcoin Core connection at all, whichever backend is fronting it: the node answering is the user's own, and a proof it built
* against headers it also supplied establishes nothing it has not already been trusted for. Cormorant declares as much in its capability, but
@@ -1691,6 +1695,10 @@ static boolean isVerifyingTransactions() {
return false;
}
+ if(isVerificationMandatory()) {
+ return true;
+ }
+
ChainTip announced = AppServices.getAnnouncedTip();
return announced == null || announced.height() >= Network.get().getHeaderCheckpoints().getMaxHeight();
}
@@ -2725,6 +2733,26 @@ static String getTipValidationError(BlockHeaderTip tip, long now) {
}
}
+ /**
+ * Sanity checks a tip announced during a session, being the connect time checks above plus the one thing only a session can establish: a chain
+ * does not rewind below a compiled in pin. A server that has announced a tip at or above the last pinned header and then announces one below it
+ * is not a server catching up, it is withdrawing the ground verification stands on, so the announcement is refused rather than acted on.
+ */
+ static String getAnnouncedTipValidationError(BlockHeaderTip tip) {
+ String tipError = getTipValidationError(tip);
+ if(tipError != null) {
+ return tipError;
+ }
+
+ int maxCheckpointHeight = Network.get().getHeaderCheckpoints().getMaxHeight();
+ if(tipReachedCheckpoints && tip.height < maxCheckpointHeight) {
+ return "Announced block header tip at height " + tip.height + " is below the last verified checkpoint at height " + maxCheckpointHeight
+ + ", which the connected server has already announced a tip above";
+ }
+
+ return null;
+ }
+
/**
* Logs and shows a status warning for an invalid tip. A hostile server can send invalid tips at any rate, so warnings are rate limited,
* and the status warning is shown at most once per episode of invalid tips (reset on any valid tip).
@@ -2743,17 +2771,20 @@ static void warnInvalidTip(String message) {
}
private static void initializeTip(BlockHeaderTip tip) {
- updateTipReceived();
+ updateTipReceived(tip.height);
//Public servers are never mid-sync, so seed the staleness clock from the tip timestamp to warn promptly on an already stale server
if(Config.get().getServerType() == ServerType.PUBLIC_ELECTRUM_SERVER) {
lastTipReceivedAt = Math.min(lastTipReceivedAt, tip.getBlockHeader().getTime() * 1000);
}
}
- static void updateTipReceived() {
+ static void updateTipReceived(int height) {
lastTipReceivedAt = System.currentTimeMillis();
staleTipWarned = false;
invalidTipWarned = false;
+ if(height >= Network.get().getHeaderCheckpoints().getMaxHeight()) {
+ tipReachedCheckpoints = true;
+ }
}
public static ServerCapability getServerCapability(List<String> serverVersion) {
@@ -2954,6 +2985,8 @@ protected FeeRatesUpdatedEvent call() throws ServerException {
if(firstCall) {
electrumServer.connect();
+ //What the last server announced says nothing about this one, and is cleared before the thread that dispatches announcements exists
+ tipReachedCheckpoints = false;
reader = new Thread(new ReadRunnable(), "ElectrumServerReadThread");
reader.setDaemon(true);
reader.setUncaughtExceptionHandler(ConnectionService.this);
### src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
@@ -22,13 +22,13 @@ public class SubscriptionService {
@JsonRpcMethod("blockchain.headers.subscribe")
public void newBlockHeaderTip(@JsonRpcParam("header") final BlockHeaderTip header) {
- String tipError = ElectrumServer.getTipValidationError(header);
+ String tipError = ElectrumServer.getAnnouncedTipValidationError(header);
if(tipError != null) {
ElectrumServer.warnInvalidTip(tipError);
return;
}
- ElectrumServer.updateTipReceived();
+ ElectrumServer.updateTipReceived(header.height);
ElectrumServer.updateRetrievedBlockHeaders(header.height, header.getBlockHeader());
Platform.runLater(() -> EventManager.get().post(new NewBlockEvent(header.height, header.getBlockHeader())));
}
### src/test/java/com/sparrowwallet/sparrow/net/ElectrumServerTest.java
@@ -206,8 +206,10 @@ public void invalidatesANodeWhoseOutputWasSpentAboveTheFork() {
@Test
public void doesNotVerifyAgainstAServerBelowTheLastPin() {
ServerCapability previousCapability = ElectrumServer.serverCapability;
+ ServerType previousServerType = Config.get().getServerType();
try {
ElectrumServer.serverCapability = new ServerCapability(false, false, false);
+ Config.get().setServerType(ServerType.ELECTRUM_SERVER);
int maxCheckpointHeight = Network.MAINNET.getHeaderCheckpoints().getMaxHeight();
BlockHeader header = Network.MAINNET.getGenesisHeader();
@@ -224,11 +226,99 @@ public void doesNotVerifyAgainstAServerBelowTheLastPin() {
ElectrumServer.serverCapability.withMerkleProofs(false);
assertFalse(ElectrumServer.isVerifyingTransactions());
} finally {
+ Config.get().setServerType(previousServerType);
ElectrumServer.serverCapability = previousCapability;
AppServices.setAnnouncedTip(null);
}
}
+ /**
+ * The lagging server accommodation is not offered where verification is mandatory. A public server was already above the last pin when it was
+ * accepted at connect, so an announced tip below it is not a server catching up, and letting it stand would hand the tier that must verify a
+ * switch the server itself chooses.
+ */
+ @Test
+ public void verifiesAgainstAMandatoryServerHoweverLowItSaysItIs() {
+ ServerCapability previousCapability = ElectrumServer.serverCapability;
+ ServerType previousServerType = Config.get().getServerType();
+ try {
+ ElectrumServer.serverCapability = new ServerCapability(false, false, false);
+ Config.get().setServerType(ServerType.PUBLIC_ELECTRUM_SERVER);
+ assertTrue(ElectrumServer.isVerificationMandatory());
+
+ AppServices.setAnnouncedTip(new ChainTip(Network.MAINNET.getHeaderCheckpoints().getMaxHeight() - 1, Network.MAINNET.getGenesisHeader()));
+ assertTrue(ElectrumServer.isVerifyingTransactions());
+ } finally {
+ Config.get().setServerType(previousServerType);
+ ElectrumServer.serverCapability = previousCapability;
+ AppServices.setAnnouncedTip(null);
+ }
+ }
+
+ /**
+ * A chain does not rewind below a compiled in pin, so a server that has announced a tip at or above the last pinned header and then announces one
+ * below it is refused. Nothing else binds the announced height to the header it arrives with, and the height is what decides whether the session
+ * verifies at all.
+ */
+ @Test
+ public void rejectsAnAnnouncedTipRegressingBelowTheLastPin() {
+ int maxCheckpointHeight = Network.MAINNET.getHeaderCheckpoints().getMaxHeight();
+ try {
+ ElectrumServer.updateTipReceived(maxCheckpointHeight);
+ assertNotNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight - 1, BLOCK_800000_HEADER_HEX)));
+
+ //The regression that would go unnoticed: a genuine current header paired with a height a couple of thousand blocks back
+ assertNotNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight - 2000, BLOCK_800000_HEADER_HEX)));
+ } finally {
+ ElectrumServer.tipReachedCheckpoints = false;
+ }
+ }
+
+ /**
+ * The path that must keep working: a private server catching up announces tip after tip below the last pin, and crosses it once. Only a tip below
+ * the pin from a server that has already announced one above it is a regression.
+ */
+ @Test
+ public void acceptsAnAnnouncedTipFromAServerStillCatchingUp() {
+ int maxCheckpointHeight = Network.MAINNET.getHeaderCheckpoints().getMaxHeight();
+ try {
+ //Nothing announced yet, as at the first announcement of a session
+ ElectrumServer.tipReachedCheckpoints = false;
+ assertNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight - 2000, BLOCK_800000_HEADER_HEX)));
+
+ ElectrumServer.updateTipReceived(maxCheckpointHeight - 2000);
+ assertNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight - 1999, BLOCK_800000_HEADER_HEX)));
+ assertNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight, BLOCK_800000_HEADER_HEX)));
+
+ //An ordinary reorg above the pin is not a regression
+ ElectrumServer.updateTipReceived(maxCheckpointHeight + 2);
+ assertNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight + 1, BLOCK_800000_HEADER_HEX)));
+ } finally {
+ ElectrumServer.tipReachedCheckpoints = false;
+ }
+ }
+
+ /**
+ * What the previous server announced is not evidence about this one. The announced tip outlives the connection that set it and is only replaced
+ * when the FX thread handles the connection event, several round trips after the reading thread starts dispatching announcements, so a server
+ * still catching up would otherwise have its first headers refused for the height its predecessor reached.
+ */
+ @Test
+ public void acceptsAnAnnouncedTipFromANewServerBelowTheOneBefore() {
+ int maxCheckpointHeight = Network.MAINNET.getHeaderCheckpoints().getMaxHeight();
+ try {
+ ElectrumServer.updateTipReceived(maxCheckpointHeight + 100);
+ AppServices.setAnnouncedTip(new ChainTip(maxCheckpointHeight + 100, Network.MAINNET.getGenesisHeader()));
+
+ //Connecting to a server still catching up, which clears the record where the reading thread is started
+ ElectrumServer.tipReachedCheckpoints = false;
+ assertNull(ElectrumServer.getAnnouncedTipValidationError(tip(maxCheckpointHeight - 2000, BLOCK_800000_HEADER_HEX)));
+ } finally {
+ ElectrumServer.tipReachedCheckpoints = false;
+ AppServices.setAnnouncedTip(null);
+ }
+ }
+
/**
* The escape hatch. It defaults on and has no user interface, and one answer has to cover the header sync, both write boundaries and the connect
* time enforcement - a public server rejected for lacking a call nothing is going to make would be no use.Why this scored 70/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.