refresh a node when its script hash status returns to an earlier value, retiring the mitigation for electrs < 0.9.0
What changed, and why it matters
This commit changes how Sparrow Wallet tracks Electrum server notifications about Bitcoin address activity. Previously, the app remembered every status value it had ever seen for each address and ignored repeats of any earlier value. Now it only remembers the latest status and treats any change—even a return to an earlier value—as a real update. The old behavior could leave the wallet showing transactions that the server no longer has (for example, after a mempool replacement or a chain reorganization), which could mislead users about their balance or transaction history.
Treat this as a correctness/reliability fix with user-security implications. Users relying on accurate wallet state should upgrade to a build containing this commit, especially if they connect to public Electrum servers or use wallets with frequent mempool activity. Review whether any downstream code assumes the old list-of-statuses behavior, since the data structure and semantics changed.
Security signals we found
State desynchronization between wallet client and Electrum server
Retired historical-status whitelist could cause stale transaction data to persist
Mempool eviction/replacement or reorg could return scripthash status to earlier value
User-visible balance or history inconsistency possible before fix
No explicit cryptographic, authentication, or remote-code-execution signal in diff
Evidence from the diff
The patch retires a mitigation originally intended for electrs versions older than 0.9.0. That mitigation stored a history of scripthash statuses in a List
Changed components
ElectrumServer.java scripthash subscription state trackingSubscriptionService.java blockchain.scripthash.subscribe handlerWallet history refresh logic triggered by scripthash status changesInspect captured patch +125 / −21
### src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -73,7 +73,7 @@ public class ElectrumServer {
static CloseableTransport transport;
- private static final Map<String, List<String>> subscribedScriptHashes = new ConcurrentHashMap<>();
+ private static final Map<String, String> subscribedScriptHashes = Collections.synchronizedMap(new HashMap<>());
private static Server previousServer;
@@ -699,9 +699,9 @@ public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<Walle
for(Map.Entry<WalletNode, ScriptHashTx[]> entry : nodeHashHistory.entrySet()) {
WalletNode node = entry.getKey();
String scriptHash = pathScriptHashes.get(node.getDerivationPath());
- List<String> statuses = subscribedScriptHashes.get(scriptHash);
+ String subscribedStatus = getSubscribedScriptHashStatus(scriptHash);
- if(statuses != null && !statuses.isEmpty()) {
+ if(subscribedStatus != null) {
//Optimize for txs that are already known (broadcasted or mempool-persisted)
for(Sha256Hash txid : candidateTxs.keySet()) {
BlockTransaction blkTx = candidateTxs.get(txid);
@@ -712,7 +712,7 @@ public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<Walle
scriptHashTxes.add(new ScriptHashTx(candidateHeights.get(txid), txid.toString(), blkTx.getFee() == null ? 0 : blkTx.getFee()));
String status = getScriptHashStatus(scriptHashTxes);
- if(Objects.equals(status, statuses.getLast())) {
+ if(Objects.equals(status, subscribedStatus)) {
entry.setValue(scriptHashTxes.toArray(new ScriptHashTx[0]));
pathScriptHashes.remove(node.getDerivationPath());
}
@@ -732,7 +732,7 @@ public void getReferences(Wallet wallet, Collection<WalletNode> nodes, Map<Walle
}
String status = getScriptHashStatus(scriptHashTxes);
- if(Objects.equals(status, statuses.getLast())) {
+ if(Objects.equals(status, subscribedStatus)) {
entry.setValue(scriptHashTxes.toArray(new ScriptHashTx[0]));
pathScriptHashes.remove(node.getDerivationPath());
}
@@ -2396,7 +2396,7 @@ public static String getScriptHash(Address address) {
return Utils.bytesToHex(reversed);
}
- public static Map<String, List<String>> getSubscribedScriptHashes() {
+ public static Map<String, String> getSubscribedScriptHashes() {
return subscribedScriptHashes;
}
@@ -2784,17 +2784,11 @@ private WalletNode createNodeForMatch(Wallet wallet, SilentPaymentScanMatch matc
}
public static String getSubscribedScriptHashStatus(String scriptHash) {
- List<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
- if(existingStatuses != null && !existingStatuses.isEmpty()) {
- return existingStatuses.get(existingStatuses.size() - 1);
- }
-
- return null;
+ return subscribedScriptHashes.get(scriptHash);
}
public static void updateSubscribedScriptHashStatus(String scriptHash, String status) {
- List<String> existingStatuses = subscribedScriptHashes.computeIfAbsent(scriptHash, k -> new ArrayList<>());
- existingStatuses.add(status);
+ subscribedScriptHashes.put(scriptHash, status);
}
public static void updateRetrievedBlockHeaders(Integer blockHeight, BlockHeader blockHeader) {
### src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
@@ -4,7 +4,6 @@
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
-import com.google.common.collect.Iterables;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.NewBlockEvent;
import com.sparrowwallet.sparrow.event.SilentPaymentsHistoryUpdatedEvent;
@@ -15,6 +14,8 @@
import org.slf4j.LoggerFactory;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
@JsonRpcService
public class SubscriptionService {
@@ -35,16 +36,15 @@ public void newBlockHeaderTip(@JsonRpcParam("header") final BlockHeaderTip heade
@JsonRpcMethod("blockchain.scripthash.subscribe")
public void scriptHashStatusUpdated(@JsonRpcParam("scripthash") final String scriptHash, @JsonRpcOptional @JsonRpcParam("status") final String status) {
- List<String> existingStatuses = ElectrumServer.getSubscribedScriptHashes().get(scriptHash);
- if(existingStatuses == null) {
+ Map<String, String> subscribedScriptHashes = ElectrumServer.getSubscribedScriptHashes();
+ if(!subscribedScriptHashes.containsKey(scriptHash)) {
log.trace("Received script hash status update for non-wallet script hash: " + scriptHash);
- } else if(status != null && existingStatuses.contains(status)) {
+ } else if(Objects.equals(status, subscribedScriptHashes.get(scriptHash))) {
log.debug("Received script hash status update, but status has not changed");
return;
} else {
- String oldStatus = Iterables.getLast(existingStatuses);
- log.debug("Status updated for script hash " + scriptHash + ", was " + oldStatus + " now " + status);
- existingStatuses.add(status);
+ log.debug("Status updated for script hash " + scriptHash + ", was " + subscribedScriptHashes.get(scriptHash) + " now " + status);
+ subscribedScriptHashes.put(scriptHash, status);
}
Platform.runLater(() -> EventManager.get().post(new WalletNodeHistoryChangedEvent(scriptHash, status)));
### src/test/java/com/sparrowwallet/sparrow/net/SubscriptionServiceTest.java
@@ -0,0 +1,110 @@
+package com.sparrowwallet.sparrow.net;
+
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+
+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.assertTrue;
+
+public class SubscriptionServiceTest {
+ @TempDir
+ private static Path tempHome;
+
+ private static final String SCRIPT_HASH = "0000000000000000000000000000000000000000000000000000000000000001";
+
+ private static final String STATUS_A = "aa00000000000000000000000000000000000000000000000000000000000000";
+ private static final String STATUS_B = "bb00000000000000000000000000000000000000000000000000000000000000";
+
+ private final SubscriptionService subscriptionService = new SubscriptionService();
+
+ @BeforeAll
+ public static void setUpAll() {
+ System.setProperty(SparrowWallet.APP_HOME_PROPERTY, tempHome.toString());
+ }
+
+ @AfterAll
+ public static void tearDownAll() {
+ System.clearProperty(SparrowWallet.APP_HOME_PROPERTY);
+ }
+
+ @BeforeEach
+ public void setUp() {
+ ElectrumServer.getSubscribedScriptHashes().clear();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ ElectrumServer.getSubscribedScriptHashes().clear();
+ }
+
+ @Test
+ public void aRepeatedStatusIsNotAChange() {
+ ElectrumServer.updateSubscribedScriptHashStatus(SCRIPT_HASH, STATUS_A);
+ assertFalse(notifyStatus(SCRIPT_HASH, STATUS_A));
+ assertEquals(STATUS_A, ElectrumServer.getSubscribedScriptHashStatus(SCRIPT_HASH));
+ }
+
+ /**
+ * A mempool transaction that is evicted or replaced returns the script hash to the status it held before the transaction arrived, and that is a
+ * change like any other: comparing against every status ever seen would leave the wallet showing a transaction the server no longer has.
+ */
+ @Test
+ public void aStatusReturningToAnEarlierValueIsAChange() {
+ ElectrumServer.updateSubscribedScriptHashStatus(SCRIPT_HASH, STATUS_A);
+ assertTrue(notifyStatus(SCRIPT_HASH, STATUS_B));
+ assertEquals(STATUS_B, ElectrumServer.getSubscribedScriptHashStatus(SCRIPT_HASH));
+
+ assertTrue(notifyStatus(SCRIPT_HASH, STATUS_A));
+ assertEquals(STATUS_A, ElectrumServer.getSubscribedScriptHashStatus(SCRIPT_HASH));
+ }
+
+ /**
+ * A script hash with no history has a null status, which is a value the subscription carries rather than an absent subscription.
+ */
+ @Test
+ public void anEmptyHistoryIsAStatusOfItsOwn() {
+ ElectrumServer.updateSubscribedScriptHashStatus(SCRIPT_HASH, null);
+ assertTrue(ElectrumServer.getSubscribedScriptHashes().containsKey(SCRIPT_HASH));
+ assertFalse(notifyStatus(SCRIPT_HASH, null));
+
+ assertTrue(notifyStatus(SCRIPT_HASH, STATUS_A));
+ assertEquals(STATUS_A, ElectrumServer.getSubscribedScriptHashStatus(SCRIPT_HASH));
+
+ //Every transaction on the script hash disappearing, as a reorg can do, empties the history again
+ assertTrue(notifyStatus(SCRIPT_HASH, null));
+ assertNull(ElectrumServer.getSubscribedScriptHashStatus(SCRIPT_HASH));
+ }
+
+ /**
+ * The decoy and recent transaction subscriptions are not wallet script hashes and are deliberately not tracked, so their notifications are
+ * passed on without being recorded - recording them would make them look like wallet nodes already subscribed to.
+ */
+ @Test
+ public void anUntrackedScriptHashIsNotRecorded() {
+ assertTrue(notifyStatus(SCRIPT_HASH, STATUS_A));
+ assertFalse(ElectrumServer.getSubscribedScriptHashes().containsKey(SCRIPT_HASH));
+ }
+
+ /**
+ * Delivers a subscription notification and returns whether it was passed on as a history change. The event is posted through Platform.runLater,
+ * and no test here starts the JavaFX toolkit, so reaching that call is observable as its refusal - a filtered notification returns before it.
+ */
+ private boolean notifyStatus(String scriptHash, String status) {
+ try {
+ subscriptionService.scriptHashStatusUpdated(scriptHash, status);
+ return false;
+ } catch(IllegalStateException e) {
+ assertEquals("Toolkit not initialized", e.getMessage());
+ return true;
+ }
+ }
+}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.