remove incomplete transaction entries before calculating the wallet balance and leave them out of the new transactions notification
What changed, and why it matters
This commit fixes a bug in the Sparrow Bitcoin wallet where the wallet could temporarily report an incorrect balance and send misleading 'new transaction' notifications. The problem happened when a transaction moving funds between two of the user's own addresses was partially known to the wallet—specifically, when the receiving side was seen before the spending side. Before this fix, the wallet would count the incoming money as new funds and notify the user, even though no actual new money had arrived. The fix delays both balance updates and notifications until the full transaction picture is available.
Treat this as a correctness/reliability fix rather than an exploitable vulnerability. Users and downstream integrators should upgrade to the patched version to avoid transient balance and notification inconsistencies. No immediate incident-response action is indicated unless a user has already acted on a false balance or notification. Review whether any plugins, scripts, or external systems consuming NewWalletTransactionsEvent rely on its timing, because the event now fires later and with a smaller set of transactions.
Security signals we found
Incorrect balance calculation from incomplete transaction state
Premature notification event emitted from unvalidated transaction data
Self-transfer / partial wallet history can produce misleading UI state
Fix reorders logic to validate completeness before balance and event emission
Regression test added for incomplete-entry exclusion behavior
Evidence from the diff
WalletTransactionsEntry.updateTransactions() previously calculated balances and posted NewWalletTransactionsEvent using the full entriesAdded list, which could include TransactionEntry objects whose inputs/outputs were not yet fully linked to the wallet’s UTXO set (isComplete(walletTxos) == false). Incomplete entries represent partial history—e.g., a self-transfer where the receiving node has been refreshed but the funding node still shows its output as unspent. The patch reorders the method so that incomplete entries are removed from the tree before calculateBalances(true) runs, and NewWalletTransactionsEvent is posted only with entriesComplete. A regression test confirms that an incomplete entry is excluded from balance, child list, and notifications until the spent-by reference is populated.
Changed components
WalletTransactionsEntry.updateTransactions()NewWalletTransactionsEvent emissionWallet balance calculationTransactionEntry completeness checkInspect captured patch +83 / −6
### src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java
@@ -88,13 +88,9 @@ public void updateTransactions() {
getChildren().removeAll(entriesRemoved);
entriesRemoved.forEach(entry -> ((TransactionEntry)entry).unregisterForConfirmations());
- calculateBalances(true);
-
+ //An incomplete entry is missing some of the wallet's inputs or outputs, so its value is not yet what the transaction moved. It is removed
+ //before the balances are calculated and left out of the notification, and is added again once the rest of the history has arrived
List<Entry> entriesComplete = entriesAdded.stream().filter(txEntry -> ((TransactionEntry)txEntry).isComplete(walletTxos)).collect(Collectors.toList());
- if(!entriesComplete.isEmpty()) {
- EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), entriesAdded.stream().map(entry -> (TransactionEntry)entry).collect(Collectors.toList())));
- }
-
if(entriesAdded.size() > entriesComplete.size()) {
Set<Entry> incompleteEntries = new HashSet<>(entriesAdded);
entriesComplete.forEach(incompleteEntries::remove);
@@ -106,6 +102,12 @@ public void updateTransactions() {
}
}
+ calculateBalances(true);
+
+ if(!entriesComplete.isEmpty()) {
+ EventManager.get().post(new NewWalletTransactionsEvent(getWallet(), entriesComplete.stream().map(entry -> (TransactionEntry)entry).collect(Collectors.toList())));
+ }
+
entriesComplete.forEach(entry -> ((TransactionEntry)entry).registerForConfirmations());
}
### src/test/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntryTest.java
@@ -17,18 +17,23 @@
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.SparrowWallet;
+import com.google.common.eventbus.Subscribe;
+import com.sparrowwallet.sparrow.event.NewWalletTransactionsEvent;
import com.sparrowwallet.sparrow.event.WalletBlockHeightChangedEvent;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Date;
+import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Which transaction entries follow the chain tip. Only an entry held in a wallet form's transactions model is shown, while a refresh builds an entry for every
@@ -132,6 +137,76 @@ public void entryAddedByARefreshFollowsTheTip() {
}
}
+ /**
+ * A transfer between two of the wallet's addresses arrives a node at a time, and until the node it was funded from has been updated the entry
+ * holds what was received and not what was spent. It is not shown, counted in the balance or notified until it is complete.
+ */
+ @Test
+ public void incompleteEntryIsLeftOutOfTheBalanceAndTheNotification() {
+ Wallet wallet = testWallet();
+ BlockTransaction funding = receive(wallet, 0, 100000L);
+ WalletTransactionsEntry walletTransactionsEntry = new WalletTransactionsEntry(wallet);
+ assertEquals(100000L, walletTransactionsEntry.getBalance());
+
+ NotificationListener listener = new NotificationListener();
+ EventManager.get().register(listener);
+ try {
+ //The receiving node has the transfer, while the funding node has yet to record its output as spent
+ BlockTransaction transfer = transfer(wallet, funding, 1, 90000L);
+ walletTransactionsEntry.updateTransactions();
+
+ assertEquals(1, walletTransactionsEntry.getChildren().size());
+ assertEquals(100000L, walletTransactionsEntry.getBalance());
+ assertTrue(listener.events.isEmpty());
+
+ //Arriving beside a complete entry, it is still not part of what that one is notified with
+ BlockTransaction received = receive(wallet, 1, 200000L);
+ walletTransactionsEntry.updateTransactions();
+
+ assertEquals(2, walletTransactionsEntry.getChildren().size());
+ assertEquals(300000L, walletTransactionsEntry.getBalance());
+ assertEquals(1, listener.events.size());
+ assertEquals(List.of(received), listener.events.getFirst().getBlockTransactions());
+ assertEquals(200000L, listener.events.getFirst().getTotalValue());
+
+ //The path that must keep working: once the funding node records the spend the entry is complete, and is shown at what it moved
+ BlockTransactionHashIndex fundingOutput = receiveNode(wallet, 0).getTransactionOutputs().iterator().next();
+ fundingOutput.setSpentBy(new BlockTransactionHashIndex(transfer.getHash(), HEIGHT, transfer.getDate(), null, 0, 100000L));
+ walletTransactionsEntry.updateTransactions();
+
+ assertEquals(3, walletTransactionsEntry.getChildren().size());
+ assertEquals(290000L, walletTransactionsEntry.getBalance());
+ assertEquals(2, listener.events.size());
+ assertEquals(List.of(transfer), listener.events.getLast().getBlockTransactions());
+ assertEquals(-10000L, listener.events.getLast().getTotalValue());
+ } finally {
+ EventManager.get().unregister(listener);
+ walletTransactionsEntry.unregisterForConfirmations();
+ }
+ }
+
+ private static BlockTransaction transfer(Wallet wallet, BlockTransaction funding, int index, long value) {
+ WalletNode node = receiveNode(wallet, index);
+ Transaction transaction = new Transaction();
+ transaction.addInput(funding.getHash(), 0, new Script(new byte[0]));
+ transaction.addOutput(value, node.getAddress());
+ Date date = new Date(1700000000000L);
+ BlockTransaction blockTransaction = new BlockTransaction(transaction.getTxId(), HEIGHT, date, null, transaction);
+ wallet.updateTransactions(Map.of(transaction.getTxId(), blockTransaction));
+ node.getTransactionOutputs().add(new BlockTransactionHashIndex(transaction.getTxId(), HEIGHT, date, null, 0, value));
+
+ return blockTransaction;
+ }
+
+ private static class NotificationListener {
+ private final List<NewWalletTransactionsEvent> events = new ArrayList<>();
+
+ @Subscribe
+ public void newWalletTransactions(NewWalletTransactionsEvent event) {
+ events.add(event);
+ }
+ }
+
private static void advanceTip(Wallet wallet, int height) {
wallet.setStoredBlockHeight(height);
EventManager.get().post(new WalletBlockHeightChangedEvent(wallet, height));Why this scored 46/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.