cormorant: purge a bitcoin core wallet transaction with only mempool conflicts once it leaves the mempool
What changed, and why it matters
This commit tightens how Sparrow's Cormorant integration decides when a Bitcoin Core wallet transaction has been replaced or dropped from the mempool. It now also considers new 'mempool conflicts' reported by Bitcoin Core 28+, waits until Core has finished loading its mempool before concluding a transaction is gone, and double-checks whether a transaction might have been confirmed in a block before purging it. The change is a defensive bug-fix that reduces the chance of Sparrow wrongly removing a transaction from its local view.
Treat as a routine bug-fix patch. Users relying on Cormorant with Bitcoin Core 28+ should upgrade. No immediate incident response is indicated, but wallet operators should verify transaction history after updating if they observed missing unconfirmed transactions around node restarts or RBF replacements.
Security signals we found
Incorrect transaction state handling could lead to premature purge of unconfirmed transactions
Defensive fix for Bitcoin Core v28+ mempool conflict reporting behavior
Race condition addressed between listSinceBlock polling and mempool/block state changes
Evidence from the diff
The patch updates BitcoindClient.isConflicted() to treat transactions with non-empty mempoolconflicts (in addition to walletconflicts) as potentially conflicted, reflecting Bitcoin Core v28+’s distinction between wallet and mempool conflicts. It also defers purge decisions until getMempoolInfo().loaded() is true, avoiding false negatives on a restarted node whose mempool is not yet loaded. Finally, when getMempoolEntry() fails, it now verifies whether the transaction has confirmations before assuming it is absent, preventing premature purges if a block was found between polls. ListTransaction and MempoolInfo records are extended to carry the new fields.
Changed components
Sparrow Cormorant Bitcoin Core integrationBitcoindClient transaction synchronizationListTransaction / MempoolInfo JSON-RPC response mappingInspect captured patch +15 / −7
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/BitcoindClient.java
@@ -2,6 +2,7 @@
import com.github.arteam.simplejsonrpc.client.JsonRpcClient;
import com.github.arteam.simplejsonrpc.client.exception.JsonRpcException;
+import com.google.common.base.Suppliers;
import com.google.common.collect.Sets;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
@@ -40,6 +41,7 @@
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Supplier;
import java.util.stream.Collectors;
public class BitcoindClient {
@@ -481,9 +483,10 @@ private synchronized void updateStore(ListSinceBlock listSinceBlock) {
List<ListTransaction> sentTransactions = new ArrayList<>();
Map<String, Boolean> conflictCache = new HashMap<>();
+ Supplier<Boolean> mempoolLoaded = Suppliers.memoize(() -> getBitcoindService().getMempoolInfo().loaded());
for(ListTransaction listTransaction : listSinceBlock.transactions()) {
- if(isConflicted(listTransaction, conflictCache)) {
+ if(isConflicted(listTransaction, conflictCache, mempoolLoaded)) {
updatedScriptHashes.addAll(store.purgeTransaction(listTransaction.txid()));
continue;
}
@@ -563,24 +566,29 @@ private void syncMempool(boolean forceRefresh) {
}
}
- private boolean isConflicted(ListTransaction listTransaction, Map<String, Boolean> conflictCache) {
- if(listTransaction.confirmations() == 0 && !listTransaction.walletconflicts().isEmpty()) {
+ private boolean isConflicted(ListTransaction listTransaction, Map<String, Boolean> conflictCache, Supplier<Boolean> mempoolLoaded) {
+ //A transaction replaced by one outside the wallet, or depending on a replaced parent, has mempool conflicts and no wallet conflicts (Bitcoin Core v28+)
+ if(listTransaction.confirmations() == 0 && (!listTransaction.walletconflicts().isEmpty() || (listTransaction.mempoolconflicts() != null && !listTransaction.mempoolconflicts().isEmpty()))) {
Boolean active = conflictCache.computeIfAbsent(listTransaction.txid(), txid -> {
try {
getBitcoindService().getMempoolEntry(txid);
return true;
} catch(JsonRpcException e) {
- return false;
+ //A block can confirm the transaction after it was listed, which leaves it for the next poll to record as confirmed
+ return getBitcoindService().getTransaction(txid, true, false).get("confirmations") instanceof Number confirmations && confirmations.intValue() > 0;
}
});
if(active) {
for(String conflictedTxid : listTransaction.walletconflicts()) {
conflictCache.put(conflictedTxid, false);
}
+
+ return false;
}
- return !active;
+ //A restarted node lists its unconfirmed transactions before it has loaded its mempool, so none can be judged absent until it has
+ return mempoolLoaded.get();
} else {
return listTransaction.confirmations() < 0;
}
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/ListTransaction.java
@@ -5,5 +5,5 @@
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
-public record ListTransaction(String address, List<String> parent_descs, Category category, double amount, int vout, double fee, int confirmations, String blockhash, int blockindex, long blocktime, int blockheight, String txid, long time, long timereceived, List<String> walletconflicts) {
+public record ListTransaction(String address, List<String> parent_descs, Category category, double amount, int vout, double fee, int confirmations, String blockhash, int blockindex, long blocktime, int blockheight, String txid, long time, long timereceived, List<String> walletconflicts, List<String> mempoolconflicts) {
}
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/bitcoind/MempoolInfo.java
@@ -3,5 +3,5 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
-public record MempoolInfo(double minrelaytxfee) {
+public record MempoolInfo(double minrelaytxfee, boolean loaded) {
}Why this scored 41/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.