register only transaction entries held in a wallets transactions model for confirmation updates, and unregister them on removal, history clear and tab close
What changed, and why it matters
This commit fixes a resource-leak bug in Sparrow Wallet's transaction list. Previously, every temporary transaction object automatically subscribed to blockchain-tip updates when it was created, and many of those temporary objects were never unsubscribed. Over time this left stale 'listeners' piling up in memory, which could slow the wallet down, waste resources, and potentially cause confusing display updates. The change makes subscription explicit: only transaction entries that are actually shown in the wallet's transaction list are registered for updates, and they are cleanly unregistered when removed, when history is cleared, or when a wallet tab is closed.
Treat as a reliability/resource-management fix. Review whether any other model objects auto-register in constructors and could leak similarly. No immediate exploit mitigation required, but users on long-running wallets should upgrade to avoid memory pressure and stale UI updates.
Security signals we found
Resource leak / listener accumulation in event bus
Potential stale-object memory retention
Risk of incorrect UI state updates from orphaned subscribers
Lifecycle mismatch between model objects and event subscriptions
Evidence from the diff
TransactionEntry previously registered itself with EventManager in its constructor whenever isFullyConfirming() was true, and only unregistered on WalletTabsClosedEvent. Temporary TransactionEntry instances built during refresh, label-change propagation, or the wallet summary balance chart therefore leaked as event subscribers. The patch removes constructor-side registration, adds explicit registerForConfirmations()/unregisterForConfirmations() methods, and has WalletTransactionsEntry call them only for children that are actually held in the model (entriesComplete on update, entriesRemoved on removal). WalletForm now registers the top-level WalletTransactionsEntry on first access and unregisters it on wallet history clear and wallet tab close, including nested wallet forms. A new unit test verifies that held entries follow the chain tip, unheld/removed entries do not, and entries added by refresh correctly start following the tip.
Changed components
com.sparrowwallet.sparrow.wallet.TransactionEntrycom.sparrowwallet.sparrow.wallet.WalletTransactionsEntrycom.sparrowwallet.sparrow.wallet.WalletFormEventManager subscription lifecycleInspect captured patch +216 / −19
### src/main/java/com/sparrowwallet/sparrow/wallet/TransactionEntry.java
@@ -8,10 +8,8 @@
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
-import com.sparrowwallet.sparrow.WalletTabData;
import com.sparrowwallet.sparrow.event.WalletBlockHeightChangedEvent;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
-import com.sparrowwallet.sparrow.event.WalletTabsClosedEvent;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.IntegerPropertyBase;
@@ -50,10 +48,6 @@ public String getName() {
return "confirmations";
}
};
-
- if(isFullyConfirming()) {
- EventManager.get().register(this);
- }
}
public BlockTransaction getBlockTransaction() {
@@ -256,6 +250,24 @@ public Long getVSizeFromTip() {
return null;
}
+ /**
+ * Only an entry held in a wallet's transactions model is shown, so only that entry follows the chain tip. Entries built to carry a label change, or
+ * built during a refresh and then found to match one already held, are never registered.
+ */
+ public void registerForConfirmations() {
+ if(isFullyConfirming()) {
+ EventManager.get().register(this);
+ }
+ }
+
+ public void unregisterForConfirmations() {
+ try {
+ EventManager.get().unregister(this);
+ } catch(IllegalArgumentException e) {
+ //Already unregistered once fully confirmed, or never registered because it was fully confirmed when adopted
+ }
+ }
+
@Subscribe
public void blockHeightChanged(WalletBlockHeightChangedEvent event) {
if(event.getWallet().equals(getWallet())) {
@@ -266,17 +278,4 @@ public void blockHeightChanged(WalletBlockHeightChangedEvent event) {
}
}
}
-
- @Subscribe
- public void walletTabsClosed(WalletTabsClosedEvent event) {
- for(WalletTabData tabData : event.getClosedWalletTabData()) {
- if(tabData.getWalletForm().getWallet() == getWallet()) {
- try {
- EventManager.get().unregister(this);
- } catch(IllegalArgumentException e) {
- //Safe to ignore
- }
- }
- }
- }
}
### src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
@@ -480,6 +480,7 @@ public NodeEntry getFreshNodeEntry(KeyPurpose keyPurpose, NodeEntry currentEntry
public WalletTransactionsEntry getWalletTransactionsEntry() {
if(walletTransactionsEntry == null) {
walletTransactionsEntry = new WalletTransactionsEntry(wallet);
+ walletTransactionsEntry.registerForConfirmations();
}
return walletTransactionsEntry;
@@ -550,6 +551,10 @@ public void walletHistoryCleared(WalletHistoryClearedEvent event) {
//Replacing the WalletForm's wallet here is only possible because we immediately clear all derived structures and do a full wallet refresh
wallet = event.getWallet();
+ //Entries bound to the replaced wallet would otherwise match neither a block height event nor a tab close for the new one
+ if(walletTransactionsEntry != null) {
+ walletTransactionsEntry.unregisterForConfirmations();
+ }
walletTransactionsEntry = null;
walletUtxosEntry = null;
accountEntries.clear();
@@ -825,9 +830,15 @@ public void walletTabsClosed(WalletTabsClosedEvent event) {
if(tabData.getWalletForm() == this) {
EventManager.get().unregister(this);
disposeRefreshNodes();
+ if(walletTransactionsEntry != null) {
+ walletTransactionsEntry.unregisterForConfirmations();
+ }
for(WalletForm nestedWalletForm : nestedWalletForms) {
EventManager.get().unregister(nestedWalletForm);
nestedWalletForm.disposeRefreshNodes();
+ if(nestedWalletForm.walletTransactionsEntry != null) {
+ nestedWalletForm.walletTransactionsEntry.unregisterForConfirmations();
+ }
}
if(wallet.isValid()) {
AppServices.clearTransactionHistoryCache(wallet);
### src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java
@@ -86,6 +86,7 @@ public void updateTransactions() {
Set<Entry> entriesRemoved = Sets.difference(previous, current);
getChildren().removeAll(entriesRemoved);
+ entriesRemoved.forEach(entry -> ((TransactionEntry)entry).unregisterForConfirmations());
calculateBalances(true);
@@ -104,6 +105,16 @@ public void updateTransactions() {
+ " children " + entry.getChildren().stream().map(e -> e.getEntryType() + " " + ((HashIndexEntry)e).getHashIndex()).collect(Collectors.toList()));
}
}
+
+ entriesComplete.forEach(entry -> ((TransactionEntry)entry).registerForConfirmations());
+ }
+
+ public void registerForConfirmations() {
+ getChildren().forEach(entry -> ((TransactionEntry)entry).registerForConfirmations());
+ }
+
+ public void unregisterForConfirmations() {
+ getChildren().forEach(entry -> ((TransactionEntry)entry).unregisterForConfirmations());
}
private static Collection<WalletTransaction> getWalletTransactions(Wallet wallet, boolean includeAllChildWallets) {
### src/test/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntryTest.java
@@ -0,0 +1,176 @@
+package com.sparrowwallet.sparrow.wallet;
+
+import com.sparrowwallet.drongo.ExtendedKey;
+import com.sparrowwallet.drongo.KeyDerivation;
+import com.sparrowwallet.drongo.KeyPurpose;
+import com.sparrowwallet.drongo.Network;
+import com.sparrowwallet.drongo.policy.Policy;
+import com.sparrowwallet.drongo.policy.PolicyType;
+import com.sparrowwallet.drongo.protocol.Script;
+import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
+import com.sparrowwallet.drongo.protocol.Transaction;
+import com.sparrowwallet.drongo.wallet.BlockTransaction;
+import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
+import com.sparrowwallet.drongo.wallet.Keystore;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import com.sparrowwallet.drongo.wallet.WalletNode;
+import com.sparrowwallet.sparrow.EventManager;
+import com.sparrowwallet.sparrow.SparrowWallet;
+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.Date;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+/**
+ * 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
+ * transaction and keeps only those it does not already hold, so an entry that registered itself on construction left a subscriber behind on every refresh.
+ */
+public class WalletTransactionsEntryTest {
+ private static final String TEST_XPUB = "xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj";
+ private static final Sha256Hash FUNDING_TXID = Sha256Hash.wrap("0000000000000000000000000000000000000000000000000000000000000001");
+ private static final int HEIGHT = 850000;
+
+ @TempDir
+ private static Path tempHome;
+
+ @BeforeAll
+ public static void setup() {
+ //Isolate Config.get(), which the balance calculation reads, from the developer's config
+ System.setProperty(SparrowWallet.APP_HOME_PROPERTY, tempHome.toString());
+ Network.set(Network.MAINNET);
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ System.clearProperty(SparrowWallet.APP_HOME_PROPERTY);
+ Network.set(null);
+ }
+
+ @Test
+ public void entryHeldInTheModelFollowsTheTip() {
+ Wallet wallet = testWallet();
+ BlockTransaction received = receive(wallet, 0, 100000L);
+ WalletTransactionsEntry walletTransactionsEntry = new WalletTransactionsEntry(wallet);
+ walletTransactionsEntry.registerForConfirmations();
+
+ try {
+ TransactionEntry held = entryFor(walletTransactionsEntry, received);
+ assertEquals(1, held.getConfirmations());
+ advanceTip(wallet, HEIGHT + 1);
+ assertEquals(2, held.getConfirmations());
+ } finally {
+ walletTransactionsEntry.unregisterForConfirmations();
+ }
+ }
+
+ /**
+ * The entries a refresh discards, those carrying a label change, and the balance chart's snapshot in the wallet summary are all built this way.
+ */
+ @Test
+ public void entryBuiltOutsideTheModelDoesNotFollowTheTip() {
+ Wallet wallet = testWallet();
+ BlockTransaction received = receive(wallet, 0, 100000L);
+ WalletTransactionsEntry walletTransactionsEntry = new WalletTransactionsEntry(wallet);
+
+ TransactionEntry unheld = entryFor(walletTransactionsEntry, received);
+ advanceTip(wallet, HEIGHT + 1);
+ assertEquals(1, unheld.getConfirmations());
+ }
+
+ @Test
+ public void entryRemovedFromTheModelStopsFollowingTheTip() {
+ Wallet wallet = testWallet();
+ BlockTransaction kept = receive(wallet, 0, 100000L);
+ BlockTransaction dropped = receive(wallet, 1, 200000L);
+ WalletTransactionsEntry walletTransactionsEntry = new WalletTransactionsEntry(wallet);
+ walletTransactionsEntry.registerForConfirmations();
+
+ try {
+ TransactionEntry keptEntry = entryFor(walletTransactionsEntry, kept);
+ TransactionEntry droppedEntry = entryFor(walletTransactionsEntry, dropped);
+
+ receiveNode(wallet, 1).getTransactionOutputs().clear();
+ walletTransactionsEntry.updateTransactions();
+ assertFalse(walletTransactionsEntry.getChildren().contains(droppedEntry));
+
+ advanceTip(wallet, HEIGHT + 1);
+ assertEquals(1, droppedEntry.getConfirmations());
+ assertEquals(2, keptEntry.getConfirmations());
+ } finally {
+ walletTransactionsEntry.unregisterForConfirmations();
+ }
+ }
+
+ @Test
+ public void entryAddedByARefreshFollowsTheTip() {
+ Wallet wallet = testWallet();
+ BlockTransaction existing = receive(wallet, 0, 100000L);
+ WalletTransactionsEntry walletTransactionsEntry = new WalletTransactionsEntry(wallet);
+ walletTransactionsEntry.registerForConfirmations();
+
+ try {
+ TransactionEntry existingEntry = entryFor(walletTransactionsEntry, existing);
+ BlockTransaction added = receive(wallet, 1, 200000L);
+ walletTransactionsEntry.updateTransactions();
+ TransactionEntry addedEntry = entryFor(walletTransactionsEntry, added);
+ assertEquals(existingEntry, entryFor(walletTransactionsEntry, existing));
+
+ advanceTip(wallet, HEIGHT + 1);
+ assertEquals(2, addedEntry.getConfirmations());
+ assertEquals(2, entryFor(walletTransactionsEntry, existing).getConfirmations());
+ } finally {
+ walletTransactionsEntry.unregisterForConfirmations();
+ }
+ }
+
+ private static void advanceTip(Wallet wallet, int height) {
+ wallet.setStoredBlockHeight(height);
+ EventManager.get().post(new WalletBlockHeightChangedEvent(wallet, height));
+ }
+
+ private static TransactionEntry entryFor(WalletTransactionsEntry walletTransactionsEntry, BlockTransaction blockTransaction) {
+ return walletTransactionsEntry.getChildren().stream().map(TransactionEntry.class::cast)
+ .filter(entry -> entry.getBlockTransaction().getHash().equals(blockTransaction.getHash())).findFirst().orElseThrow();
+ }
+
+ private static BlockTransaction receive(Wallet wallet, int index, long value) {
+ WalletNode node = receiveNode(wallet, index);
+ Transaction transaction = new Transaction();
+ transaction.addInput(FUNDING_TXID, index, 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 WalletNode receiveNode(Wallet wallet, int index) {
+ return wallet.getNode(KeyPurpose.RECEIVE).getChildren().stream().filter(node -> node.getIndex() == index).findFirst().orElseThrow();
+ }
+
+ private static Wallet testWallet() {
+ Wallet wallet = new Wallet();
+ wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setScriptType(ScriptType.P2WPKH);
+ Keystore keystore = new Keystore();
+ keystore.setKeyDerivation(new KeyDerivation("00000000", "m/84'/0'/0'"));
+ keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(TEST_XPUB));
+ wallet.getKeystores().add(keystore);
+ wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), 1));
+ wallet.getNode(KeyPurpose.RECEIVE).fillToIndex(wallet, 1);
+ wallet.setStoredBlockHeight(HEIGHT);
+
+ return wallet;
+ }
+}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.