mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-09-14 00:45:08 +00:00
verify proof of work on chain tips and warn when a tip goes stale
This commit is contained in:
+1
-1
Submodule drongo updated: d6e53ec2b4...4407ca7342
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user