verify proof of work on chain tips and warn when a tip goes stale
What changed, and why it matters
This commit adds safety checks to the Bitcoin wallet Sparrow when it receives block-chain tip announcements from an Electrum server. It now verifies that the announced block header is properly formatted, not timestamped too far in the future, and meets the proof-of-work target encoded in the header itself. It also warns the user if the server stops sending new blocks for more than two hours, which can indicate a stale or malicious server. These changes reduce the risk of a hostile or malfunctioning server misleading the wallet about the state of the Bitcoin network.
Review the `verifyProofOfWork()` implementation in the `drongo` dependency to confirm it checks nBits correctly and is not bypassable. Ensure the 4-hour future-time window and 2-hour staleness threshold are appropriate for all supported networks. Consider whether invalid tips during subscription should disconnect or fall back to another server rather than only logging a warning.
Security signals we found
Adds proof-of-work verification on server-announced chain tips
Adds future-timestamp rejection for announced chain tips
Adds staleness warning when no new mainnet tip is received for >2 hours
Rate-limits UI warnings to avoid hostile-server spam
Caches parsed block header to avoid repeated deserialization
Throws `ServerException` on invalid initial tip during subscription setup
Evidence from the diff
The patch introduces ElectrumServer.getTipValidationError() which validates BlockHeaderTip objects: parses the 80-byte header, calls BlockHeader.verifyProofOfWork() against the header’s own nBits target, rejects negative heights or missing hex, and rejects timestamps more than 4 hours in the future. Valid tips update lastTipReceivedAt and reset warning flags; invalid tips are logged and shown via a rate-limited status warning. A staleness check (checkTipStaleness()) warns if no new mainnet tip has been received for over 2 hours. BlockHeaderTip caches the parsed BlockHeader to avoid repeated parsing. Unit tests cover real, genesis, future, tampered, and malformed headers.
Changed components
ElectrumServer connection and subscription logicBlockHeaderTip parsing/cachingSubscriptionService new-block-header callbackServer tip validation and staleness warning subsystemInspect captured patch +180 / −5
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/BlockHeaderTip.java b/src/main/java/com/sparrowwallet/sparrow/net/BlockHeaderTip.java
index ed41d9f..a9a582b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/BlockHeaderTip.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/BlockHeaderTip.java
@@ -8,12 +8,17 @@ public class BlockHeaderTip {
public int height;
public String hex;
+ private volatile BlockHeader blockHeader;
+
public BlockHeader getBlockHeader() {
- if(hex == null) {
- return new BlockHeader(0, Sha256Hash.ZERO_HASH, Sha256Hash.ZERO_HASH, Sha256Hash.ZERO_HASH, 0, 0, 0);
+ if(blockHeader == null) {
+ if(hex == null) {
+ blockHeader = new BlockHeader(0, Sha256Hash.ZERO_HASH, Sha256Hash.ZERO_HASH, Sha256Hash.ZERO_HASH, 0, 0, 0);
+ } else {
+ blockHeader = new BlockHeader(Utils.hexToBytes(hex));
+ }
}
- byte[] blockHeaderBytes = Utils.hexToBytes(hex);
- return new BlockHeader(blockHeaderBytes);
+ return blockHeader;
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
index 180de19..db6f70e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -91,6 +91,22 @@ public class ElectrumServer {
private static final int TAPROOT_ACTIVATION_HEIGHT = 709632;
+ //Consensus rejects blocks timestamped more than 2 hours in the future, extended here to allow for local clock skew
+ private static final long MAXIMUM_FUTURE_TIP_TIME_SECS = 4 * 60 * 60;
+
+ //A gap of over 2 hours between mainnet blocks occurs naturally roughly once every 3 years
+ private static final long STALE_TIP_WARNING_AGE_MILLIS = 2 * 60 * 60 * 1000;
+
+ private static final long TIP_WARNING_INTERVAL_MILLIS = 60 * 1000;
+
+ private static volatile long lastTipReceivedAt;
+
+ private static volatile boolean staleTipWarned;
+
+ private static volatile boolean invalidTipWarned;
+
+ private static volatile long lastTipWarningLoggedAt;
+
private final static Map<String, Integer> subscribedRecent = new ConcurrentHashMap<>();
private final static Map<String, String> broadcastRecent = new ConcurrentHashMap<>();
@@ -1777,6 +1793,67 @@ public class ElectrumServer {
retrievedBlockHeaders.put(blockHeight, blockHeader);
}
+ /**
+ * Sanity checks a server announced chain tip, returning the reason it is invalid, or null if it is valid.
+ * The header must parse, must not be timestamped in the future, and must meet its own claimed proof of work target.
+ */
+ static String getTipValidationError(BlockHeaderTip tip) {
+ return getTipValidationError(tip, System.currentTimeMillis());
+ }
+
+ static String getTipValidationError(BlockHeaderTip tip, long now) {
+ try {
+ if(tip.height < 0 || tip.hex == null) {
+ return "Announced block header tip at height " + tip.height + " is missing or malformed";
+ }
+
+ BlockHeader blockHeader = tip.getBlockHeader();
+ if(!blockHeader.verifyProofOfWork()) {
+ return "Announced block header at height " + tip.height + " does not meet its claimed proof of work target";
+ }
+
+ long nowSecs = now / 1000;
+ if(blockHeader.getTime() > nowSecs + MAXIMUM_FUTURE_TIP_TIME_SECS) {
+ return "Announced block header at height " + tip.height + " is timestamped " + ((blockHeader.getTime() - nowSecs) / 3600) + " hours in the future, indicating either an invalid header or a slow system clock";
+ }
+
+ return null;
+ } catch(Exception e) {
+ return "Error parsing announced block header at height " + tip.height + ": " + e;
+ }
+ }
+
+ /**
+ * 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).
+ */
+ static void warnInvalidTip(String message) {
+ long now = System.currentTimeMillis();
+ if(now - lastTipWarningLoggedAt > TIP_WARNING_INTERVAL_MILLIS) {
+ lastTipWarningLoggedAt = now;
+ log.warn(message);
+
+ if(!invalidTipWarned) {
+ invalidTipWarned = true;
+ Platform.runLater(() -> EventManager.get().post(new StatusEvent("Warning: Ignoring invalid block header announced by the server", 120)));
+ }
+ }
+ }
+
+ private static void initializeTip(BlockHeaderTip tip) {
+ updateTipReceived();
+ //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() {
+ lastTipReceivedAt = System.currentTimeMillis();
+ staleTipWarned = false;
+ invalidTipWarned = false;
+ }
+
public static ServerCapability getServerCapability(List<String> serverVersion) {
if(!serverVersion.isEmpty()) {
String server = serverVersion.getFirst().toLowerCase(Locale.ROOT);
@@ -2004,6 +2081,11 @@ public class ElectrumServer {
BlockHeaderTip tip;
if(subscribe) {
tip = electrumServer.subscribeBlockHeaders();
+ String tipError = getTipValidationError(tip);
+ if(tipError != null) {
+ throw new ServerException(tipError);
+ }
+ initializeTip(tip);
subscribedScriptHashes.clear();
} else {
tip = new BlockHeaderTip();
@@ -2024,6 +2106,7 @@ public class ElectrumServer {
} else {
if(reader.isAlive()) {
electrumServer.ping();
+ checkTipStaleness();
long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt;
if(elapsed > FEE_RATES_PERIOD) {
@@ -2043,6 +2126,16 @@ public class ElectrumServer {
};
}
+ private void checkTipStaleness() {
+ if(subscribe && Network.get() == Network.MAINNET && lastTipReceivedAt > 0 && !staleTipWarned && System.currentTimeMillis() - lastTipReceivedAt > STALE_TIP_WARNING_AGE_MILLIS) {
+ staleTipWarned = true;
+ long hours = (System.currentTimeMillis() - lastTipReceivedAt) / (60 * 60 * 1000);
+ String warning = "Warning: The connected server has not announced a new block for over " + hours + " hours, so its chain view may be stale";
+ log.warn(warning);
+ Platform.runLater(() -> EventManager.get().post(new StatusEvent(warning, 120)));
+ }
+ }
+
public void closeConnection() {
try {
closeActiveConnection();
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
index 4117b5b..b0ff519 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
@@ -22,6 +22,13 @@ public class SubscriptionService {
@JsonRpcMethod("blockchain.headers.subscribe")
public void newBlockHeaderTip(@JsonRpcParam("header") final BlockHeaderTip header) {
+ String tipError = ElectrumServer.getTipValidationError(header);
+ if(tipError != null) {
+ ElectrumServer.warnInvalidTip(tipError);
+ return;
+ }
+
+ ElectrumServer.updateTipReceived();
ElectrumServer.updateRetrievedBlockHeaders(header.height, header.getBlockHeader());
Platform.runLater(() -> EventManager.get().post(new NewBlockEvent(header.height, header.getBlockHeader())));
}
diff --git a/src/test/java/com/sparrowwallet/sparrow/net/ElectrumServerTest.java b/src/test/java/com/sparrowwallet/sparrow/net/ElectrumServerTest.java
new file mode 100644
index 0000000..762f52b
--- /dev/null
+++ b/src/test/java/com/sparrowwallet/sparrow/net/ElectrumServerTest.java
@@ -0,0 +1,70 @@
+package com.sparrowwallet.sparrow.net;
+
+import com.sparrowwallet.drongo.Network;
+import com.sparrowwallet.drongo.Utils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class ElectrumServerTest {
+ private static final String GENESIS_HEADER_HEX = "0100000000000000000000000000000000000000000000000000000000000000000000003ba3edfd7a7b12b27ac72c3e67768f617fc81bc3888a51323a9fb8aa4b1e5e4a29ab5f49ffff001d1dac2b7c";
+ private static final long GENESIS_TIME_SECS = 1231006505L;
+
+ private static final String BLOCK_800000_HEADER_HEX = "00601d3455bb9fbd966b3ea2dc42d0c22722e4c0c1729fad17210100000000000000000055087fab0c8f3f89f8bcfd4df26c504d81b0a88e04907161838c0c53001af09135edbd64943805175e955e06";
+ private static final long BLOCK_800000_TIME_SECS = 1690168629L;
+
+ @BeforeEach
+ public void setUp() {
+ Network.set(Network.MAINNET);
+ }
+
+ @Test
+ public void acceptsRealHeader() {
+ assertNull(ElectrumServer.getTipValidationError(tip(800000, BLOCK_800000_HEADER_HEX), (BLOCK_800000_TIME_SECS + 3600) * 1000));
+ }
+
+ /**
+ * Proof of work is checked against the header's own claimed target, so a genuine difficulty 1 header is valid on its own terms, however old it is and
+ * whatever height it is announced at. Establishing that a header belongs to the chain at the announced height requires linkage from a known checkpoint.
+ */
+ @Test
+ public void acceptsGenuineHeaderRegardlessOfAgeAndAnnouncedHeight() {
+ assertNull(ElectrumServer.getTipValidationError(tip(0, GENESIS_HEADER_HEX), (GENESIS_TIME_SECS + 3600) * 1000));
+ assertNull(ElectrumServer.getTipValidationError(tip(950000, GENESIS_HEADER_HEX), System.currentTimeMillis()));
+ }
+
+ @Test
+ public void rejectsFutureTimestampedHeader() {
+ assertNotNull(ElectrumServer.getTipValidationError(tip(800000, BLOCK_800000_HEADER_HEX), (BLOCK_800000_TIME_SECS - 5 * 60 * 60) * 1000));
+ }
+
+ @Test
+ public void rejectsTamperedHeader() {
+ byte[] tampered = Utils.hexToBytes(BLOCK_800000_HEADER_HEX);
+ tampered[79] ^= 0x01;
+ assertNotNull(ElectrumServer.getTipValidationError(tip(800000, Utils.bytesToHex(tampered)), (BLOCK_800000_TIME_SECS + 3600) * 1000));
+ }
+
+ @Test
+ public void rejectsMalformedTips() {
+ long now = (BLOCK_800000_TIME_SECS + 3600) * 1000;
+ assertNotNull(ElectrumServer.getTipValidationError(tip(800000, null), now));
+ assertNotNull(ElectrumServer.getTipValidationError(tip(-1, BLOCK_800000_HEADER_HEX), now));
+ assertNotNull(ElectrumServer.getTipValidationError(tip(800000, "cafebabe"), now));
+ }
+
+ private BlockHeaderTip tip(int height, String hex) {
+ BlockHeaderTip tip = new BlockHeaderTip();
+ tip.height = height;
+ tip.hex = hex;
+ return tip;
+ }
+
+ @AfterEach
+ public void tearDown() throws Exception {
+ Network.set(null);
+ }
+}
Why this scored 62/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.