From 711ded4625cb72b951205aa27a8f359e00918e7f Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Thu, 1 Oct 2026 14:55:37 +0200 Subject: [PATCH] remove incomplete transaction entries before calculating the wallet balance and leave them out of the new transactions notification --- .../wallet/WalletTransactionsEntry.java | 14 ++-- .../wallet/WalletTransactionsEntryTest.java | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java index 9cfa1b7b..ae99062f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntry.java @@ -88,13 +88,9 @@ public class WalletTransactionsEntry extends Entry { 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 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 incompleteEntries = new HashSet<>(entriesAdded); entriesComplete.forEach(incompleteEntries::remove); @@ -106,6 +102,12 @@ public class WalletTransactionsEntry extends Entry { } } + 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()); } diff --git a/src/test/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntryTest.java b/src/test/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntryTest.java index 101679c5..096352eb 100644 --- a/src/test/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntryTest.java +++ b/src/test/java/com/sparrowwallet/sparrow/wallet/WalletTransactionsEntryTest.java @@ -17,6 +17,8 @@ import com.sparrowwallet.drongo.wallet.Wallet; 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; @@ -24,11 +26,14 @@ 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 class WalletTransactionsEntryTest { } } + /** + * 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 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));