cormorant: replace a transaction entry moved within its block, hold entries sharing a block position, and stop relisting unconfirmed entries as updates
What changed, and why it matters
This commit fixes bookkeeping bugs in how Sparrow Wallet tracks Bitcoin transactions when they move around in a block or sit unconfirmed. Before the fix, the wallet could report the same transaction as a new 'update' every time it was polled, even though nothing had changed. In rare cases after a blockchain reorganization, a transaction that changed position in a block might not be recorded correctly, or two transactions sharing the same temporary position could be handled wrong. The patch makes the wallet store entries more carefully and adds tests for these scenarios. There is no direct evidence this is exploitable by an attacker, but it could cause incorrect wallet state or missed notifications.
Review the comparator contract for consistency with equals/hashCode and ensure the new tests pass. Monitor for any follow-up fixes related to reorg handling. No urgent security deployment is indicated, but the fix should be included in the next release because it prevents incorrect wallet state and notification spam.
Security signals we found
Incorrect state tracking after blockchain reorganization
Duplicate/missing transaction entries due to comparator/Set semantics
Unconfirmed transactions repeatedly reported as updates
Unit tests added to cover edge cases
Evidence from the diff
The change is in the Cormorant (Electrum-style RPC index) store. It addresses three related issues: (1) a confirmed transaction that moves to a different index within the same block after a reorg is now replaced rather than leaving a stale entry or being re-added as an update; (2) the TxEntry comparator now uses tx_hash as the final tie-breaker so two entries that temporarily share a block position (e.g., old and new positions during a reorg) can both be held in the TreeSet; (3) unconfirmed transactions already held are no longer re-added on every wallet poll, preventing repeated false ‘updates’. The commit adds unit tests covering reorg position changes, vacated positions being filled by another transaction, and unconfirmed transactions not being re-reported. The diff is a targeted correctness fix with no cryptographic or network-layer changes.
Changed components
com.sparrowwallet.sparrow.net.cormorant.index.Storecom.sparrowwallet.sparrow.net.cormorant.index.TxEntryCormorant wallet index synchronization logicInspect captured patch +82 / −7
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/Store.java
@@ -35,14 +35,20 @@ public synchronized String addAddressTransaction(Address address, ListTransactio
mempoolEntries.put(txid, null);
}
entries.removeIf(txe -> txe.height > 0 && txe.tx_hash.equals(listTransaction.txid()));
- txEntry = new TxEntry(0, 0, listTransaction.txid(), listTransaction.fee());
+ //An unconfirmed entry already held is kept current by updateMempoolTransactions, at the height its parents give it. Adding it again here
+ //would report it as an update each time the wallet lists it
+ boolean held = entries.stream().anyMatch(txe -> txe.height <= 0 && txe.tx_hash.equals(listTransaction.txid()));
+ txEntry = held ? null : new TxEntry(0, 0, listTransaction.txid(), listTransaction.fee());
} else {
mempoolEntries.remove(txid);
- entries.removeIf(txe -> txe.height != listTransaction.blockheight() && txe.tx_hash.equals(listTransaction.txid()));
- txEntry = new TxEntry(listTransaction.blockheight(), listTransaction.blockindex(), listTransaction.txid());
+ TxEntry confirmedEntry = new TxEntry(listTransaction.blockheight(), listTransaction.blockindex(), listTransaction.txid());
+ //A reorg can confirm the transaction again in another block, or at the same height in another position. An entry unchanged is left in place
+ //so that it is not reported as an update each time the wallet lists it
+ entries.removeIf(txe -> txe.tx_hash.equals(listTransaction.txid()) && txe.compareTo(confirmedEntry) != 0);
+ txEntry = confirmedEntry;
}
- if(entries.add(txEntry)) {
+ if(txEntry != null && entries.add(txEntry)) {
return scriptHash;
}
### src/main/java/com/sparrowwallet/sparrow/net/cormorant/index/TxEntry.java
@@ -60,11 +60,12 @@ public int compareTo(TxEntry o) {
return height - o.height;
}
- if(height <= 0) {
- return tx_hash.compareTo(o.tx_hash);
+ if(height > 0 && index != o.index) {
+ return index - o.index;
}
- return index - o.index;
+ //Two transactions share a position in a block only while one of them is an entry a reorg has yet to replace, and both must be held until it is
+ return tx_hash.compareTo(o.tx_hash);
}
@Override
### src/test/java/com/sparrowwallet/sparrow/net/cormorant/index/StoreTest.java
@@ -3,14 +3,18 @@
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.FeesMempoolEntry;
import com.sparrowwallet.sparrow.net.cormorant.bitcoind.ListTransaction;
+import com.sparrowwallet.sparrow.net.cormorant.bitcoind.MempoolEntry;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
+import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
public class StoreTest {
private static final String FIRST_TXID = "0000000000000000000000000000000000000000000000000000000000000001";
@@ -34,6 +38,70 @@ public void testHistoryIsUnaffectedByLaterUpdates() throws InvalidAddressExcepti
assertEquals(List.of(SECOND_TXID), store.getHistory(scriptHash).stream().map(txEntry -> txEntry.tx_hash).toList());
}
+ /**
+ * A reorg that confirms a transaction again at the same height can place it elsewhere in the block. It is listed once, at its new position, and
+ * listing it again unchanged - which the wallet does on every poll until the next block - is not an update.
+ */
+ @Test
+ public void testTransactionMovedWithinItsBlockIsListedOnce() throws InvalidAddressException {
+ Store store = new Store();
+ Address address = Address.fromString("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
+ String scriptHash = Store.getScriptHash(address);
+ assertEquals(scriptHash, store.addAddressTransaction(address, transaction(address, FIRST_TXID, 3)));
+ assertNull(store.addAddressTransaction(address, transaction(address, FIRST_TXID, 3)));
+
+ assertEquals(scriptHash, store.addAddressTransaction(address, transaction(address, FIRST_TXID, 5)));
+ assertEquals(List.of(FIRST_TXID), store.getHistory(scriptHash).stream().map(txEntry -> txEntry.tx_hash).toList());
+ assertNull(store.addAddressTransaction(address, transaction(address, FIRST_TXID, 5)));
+ }
+
+ /**
+ * The position such a transaction leaves can be taken by another to the same address, and the wallet may list either of them first.
+ */
+ @Test
+ public void testTransactionTakingAVacatedPositionIsListed() throws InvalidAddressException {
+ Address address = Address.fromString("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
+ String scriptHash = Store.getScriptHash(address);
+
+ Store movedFirst = new Store();
+ movedFirst.addAddressTransaction(address, transaction(address, FIRST_TXID, 3));
+ movedFirst.addAddressTransaction(address, transaction(address, FIRST_TXID, 5));
+ assertEquals(scriptHash, movedFirst.addAddressTransaction(address, transaction(address, SECOND_TXID, 3)));
+ assertEquals(List.of(SECOND_TXID, FIRST_TXID), movedFirst.getHistory(scriptHash).stream().map(txEntry -> txEntry.tx_hash).toList());
+
+ Store movedLast = new Store();
+ movedLast.addAddressTransaction(address, transaction(address, FIRST_TXID, 3));
+ assertEquals(scriptHash, movedLast.addAddressTransaction(address, transaction(address, SECOND_TXID, 3)));
+ movedLast.addAddressTransaction(address, transaction(address, FIRST_TXID, 5));
+ assertEquals(List.of(SECOND_TXID, FIRST_TXID), movedLast.getHistory(scriptHash).stream().map(txEntry -> txEntry.tx_hash).toList());
+ }
+
+ /**
+ * An unconfirmed transaction with unconfirmed parents is held at a height of its own, and the wallet goes on listing it on every poll. Neither
+ * listing it nor refreshing it from the mempool is an update while nothing about it has changed.
+ */
+ @Test
+ public void testUnconfirmedTransactionWithUnconfirmedParentsIsNotUpdatedByRelisting() throws InvalidAddressException {
+ Store store = new Store();
+ Address address = Address.fromString("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
+ String scriptHash = Store.getScriptHash(address);
+ ListTransaction unconfirmed = new ListTransaction(address.toString(), List.of(), Category.receive, 1.0, 0, 0.0, 0, null, 0, 0, 0, FIRST_TXID, 0, 0, List.of(), List.of());
+
+ assertEquals(scriptHash, store.addAddressTransaction(address, unconfirmed));
+ store.getMempoolEntries().put(FIRST_TXID, new MempoolEntry(100, 200, true, new FeesMempoolEntry(0.00001, 0.00002)));
+ assertEquals(Set.of(scriptHash), store.updateMempoolTransactions());
+ assertEquals(List.of(-1), store.getHistory(scriptHash).stream().map(txEntry -> txEntry.height).toList());
+ String status = store.getStatus(scriptHash);
+
+ assertNull(store.addAddressTransaction(address, unconfirmed));
+ assertEquals(Set.of(), store.updateMempoolTransactions());
+ assertEquals(status, store.getStatus(scriptHash));
+
+ //The path that must keep working: once it confirms, the unconfirmed entry is replaced
+ assertEquals(scriptHash, store.addAddressTransaction(address, transaction(address, FIRST_TXID, 0)));
+ assertEquals(List.of(840000), store.getHistory(scriptHash).stream().map(txEntry -> txEntry.height).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 33/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.