lock the cormorant store against client connection reads and serve history as a copy, and close the client socket however its handler exits
What changed, and why it matters
This commit fixes two reliability issues in Sparrow Wallet's built-in Electrum server (Cormorant). First, it makes sure the internal transaction store is locked while being read or updated, and returns a fresh copy of a wallet's history so a slow client doesn't see data change mid-request. Second, it ensures the client network socket is always closed when the handler finishes, even if something goes wrong. These are defensive fixes that prevent data races and resource leaks rather than obvious user-facing exploits.
Treat as a worthwhile hardening patch. Review whether any other shared Cormorant state (mempool maps, block height maps) is exposed directly without synchronization or defensive copies, and ensure all client sockets and streams are closed on abnormal handler exits.
Security signals we found
Concurrency: shared mutable store accessed by client handler and polling threads now synchronized
Data consistency: history returned as a defensive copy to avoid iterator seeing concurrent modifications
Resource leak: client socket now closed in finally block regardless of exception path
Tests added to assert both the copy behavior and socket closure
Evidence from the diff
The patch adds synchronized to all mutating and read methods on Store (addAddressTransaction, updateMempoolTransactions, purgeTransaction, getStatus, getFundingAddress, getHistory, getBlockHash) and changes getHistory to return a new ArrayList copy instead of the live Set. It also adds a finally block in RequestHandler.run() to close clientSocket on any exit path, plus tests verifying the copy-on-read behavior and socket closure. The changes address concurrency safety and socket leak issues in the Cormorant Electrum bridge.
Changed components
Sparrow Wallet Cormorant Electrum servercom.sparrowwallet.sparrow.net.cormorant.index.Storecom.sparrowwallet.sparrow.net.cormorant.electrum.RequestHandlerInspect captured patch +57 / −9
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/electrum/RequestHandler.java
@@ -52,6 +52,12 @@ public void run() {
}
} catch(IOException e) {
log.error("Could not communicate with client socket", e);
+ } finally {
+ try {
+ clientSocket.close();
+ } catch(IOException e) {
+ log.debug("Error closing client socket", e);
+ }
}
}
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/Store.java
@@ -18,7 +18,7 @@ public class Store {
private final Map<Integer, String> blockHeightHashes = new HashMap<>();
private final Map<String, MempoolEntry> mempoolEntries = new HashMap<>();
- public String addAddressTransaction(Address address, ListTransaction listTransaction) {
+ public synchronized String addAddressTransaction(Address address, ListTransaction listTransaction) {
if(listTransaction.category() == Category.receive || listTransaction.category() == Category.immature || listTransaction.category() == Category.generate) {
fundingAddresses.put(new HashIndex(Sha256Hash.wrap(listTransaction.txid()), listTransaction.vout()), address);
}
@@ -49,7 +49,7 @@ public String addAddressTransaction(Address address, ListTransaction listTransac
return null;
}
- public Set<String> updateMempoolTransactions() {
+ public synchronized Set<String> updateMempoolTransactions() {
Set<String> updatedScriptHashes = new HashSet<>();
for(Map.Entry<String, Set<TxEntry>> scriptHashEntry : scriptHashEntries.entrySet()) {
@@ -80,7 +80,7 @@ public Set<String> updateMempoolTransactions() {
return updatedScriptHashes;
}
- public Set<String> purgeTransaction(String txid) {
+ public synchronized Set<String> purgeTransaction(String txid) {
Set<String> updatedScriptHashes = new HashSet<>();
for(Map.Entry<String, Set<TxEntry>> scriptHashEntry : scriptHashEntries.entrySet()) {
@@ -98,7 +98,7 @@ public Set<String> purgeTransaction(String txid) {
return updatedScriptHashes;
}
- public String getStatus(String scriptHash) {
+ public synchronized String getStatus(String scriptHash) {
Set<TxEntry> entries = scriptHashEntries.get(scriptHash);
if(entries == null || entries.isEmpty()) {
return null;
@@ -112,7 +112,7 @@ public String getStatus(String scriptHash) {
return Utils.bytesToHex(Sha256Hash.hash(scriptHashStatus.toString().getBytes(StandardCharsets.UTF_8)));
}
- public Address getFundingAddress(HashIndex spentOutput) {
+ public synchronized Address getFundingAddress(HashIndex spentOutput) {
return fundingAddresses.get(spentOutput);
}
@@ -124,16 +124,16 @@ public Map<String, MempoolEntry> getMempoolEntries() {
return mempoolEntries;
}
- public Set<TxEntry> getHistory(String scriptHash) {
+ public synchronized List<TxEntry> getHistory(String scriptHash) {
Set<TxEntry> entries = scriptHashEntries.get(scriptHash);
if(entries == null) {
- return Collections.emptySet();
+ return Collections.emptyList();
}
- return entries;
+ return new ArrayList<>(entries);
}
- public String getBlockHash(int height) {
+ public synchronized String getBlockHash(int height) {
return blockHeightHashes.get(height);
}
### src/test/java/com/sparrowwallet/sparrow/net/cormorant/electrum/RequestHandlerTest.java
@@ -104,6 +104,7 @@ public OutputStream getOutputStream() {
endOfInput.countDown();
handler.get(5, TimeUnit.SECONDS);
+ assertTrue(socket.isClosed(), "The handler must close the client socket when it exits");
List<String> lines = written.toString(StandardCharsets.UTF_8).lines().toList();
assertEquals(2, lines.size(), "The response and the notification must each arrive as one whole line");
### src/test/java/com/sparrowwallet/sparrow/net/cormorant/index/StoreTest.java
@@ -0,0 +1,41 @@
+package com.sparrowwallet.sparrow.net.cormorant.index;
+
+import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.drongo.address.InvalidAddressException;
+import com.sparrowwallet.sparrow.net.cormorant.bitcoind.Category;
+import com.sparrowwallet.sparrow.net.cormorant.bitcoind.ListTransaction;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class StoreTest {
+ private static final String FIRST_TXID = "0000000000000000000000000000000000000000000000000000000000000001";
+ private static final String SECOND_TXID = "0000000000000000000000000000000000000000000000000000000000000002";
+
+ @Test
+ public void testHistoryIsUnaffectedByLaterUpdates() throws InvalidAddressException {
+ Store store = new Store();
+ Address address = Address.fromString("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
+ store.addAddressTransaction(address, transaction(address, FIRST_TXID, 0));
+ store.addAddressTransaction(address, transaction(address, SECOND_TXID, 1));
+ String scriptHash = Store.getScriptHash(address);
+
+ //A client connection serialises the history after it is returned, while the polling thread goes on updating the store
+ Iterator<TxEntry> history = store.getHistory(scriptHash).iterator();
+ store.purgeTransaction(FIRST_TXID);
+
+ List<String> served = new ArrayList<>();
+ history.forEachRemaining(txEntry -> served.add(txEntry.tx_hash));
+ assertEquals(List.of(FIRST_TXID, SECOND_TXID), served, "The history returned must be the one at the time it was requested");
+ assertEquals(List.of(SECOND_TXID), store.getHistory(scriptHash).stream().map(txEntry -> txEntry.tx_hash).toList());
+ }
+
+ private ListTransaction transaction(Address address, String txid, int blockIndex) {
+ return new ListTransaction(address.toString(), List.of(), Category.receive, 1.0, 0, 0.0, 1, "0000000000000000000000000000000000000000000000000000000000000003",
+ blockIndex, 0, 840000, txid, 0, 0, List.of(), List.of());
+ }
+}Why this scored 35/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.