pre-populate sp self-spend node outputs to avoid partial first notification
What changed, and why it matters
This commit fixes a display timing issue in the Sparrow bitcoin wallet. When a user makes a payment to themselves (a 'self-spend'), the wallet could briefly show an incomplete transaction notification—showing the money leaving but not the matching change coming back. The patch pre-fills internal wallet records so the first notification is complete and accurate. It is a correctness/UX fix, not a vulnerability that lets an attacker steal funds.
Treat as a routine bugfix/UX improvement. No urgent security action required. Reviewers may verify that the pre-populated TXOs are correctly overwritten on the next authoritative refresh and that no stale spentBy references persist.
Security signals we found
No attacker-controlled input path is introduced
No cryptographic, signature, or key-handling code is changed
No network trust model or authentication change
Fixes a UI consistency/timing issue, not a confidentiality or integrity failure
Change is localized to transaction-output bookkeeping in ElectrumServer.java
Evidence from the diff
The change is in ElectrumServer’s wallet history processing. It pre-populates newly discovered silent-payment (SP) self-spend output nodes and pre-links spent TXOs to their spending inputs before the authoritative Electrum server scripthash subscription catches up. Previously, a lag between SP discovery and scripthash index updates caused calculateNodeHistory to skip the new node, so the first WalletHistoryChangedEvent had isComplete=false and the UI showed a partial spend notification. The patch builds initial TXO sets and spentBy references from the already-fetched transaction data, then later refreshes reconcile against authoritative server history via WalletNode.updateTransactionOutputs’s hash/index preservation logic.
Changed components
src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.javaWallet history / transaction notification pipelineSilent payment (SP) self-spend handlingInspect captured patch +61 / −8
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
index 74feec0..c1187a3 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -1593,6 +1593,23 @@ public class ElectrumServer {
if(newNode != null) {
affectedNodes.add(newNode);
walletAddresses.put(wallet.getAddress(newNode), newNode);
+
+ //Pre-populate the new SP node's transactionOutputs from the match data so the
+ //first WalletHistoryChangedEvent's isComplete check sees both sides of the spend
+ //already linked. Without this, the new SP node remains unmarked in
+ //subscribeWalletNodes (its subscribe-response status is typically null if
+ //server's scripthash index lags the SP discovery channel), calculateNodeHistory
+ //never runs for it in this pass, and the user sees a partial first notification
+ //(spend without self-change credit) corrected by a second notification when the
+ //scripthash push catches up. A later refresh's calculateNodeHistory rebuilds the
+ //TXOs from authoritative server history; the matching-(hash,index) preservation
+ //logic in WalletNode.updateTransactionOutputs keeps labels/statuses intact.
+ TransactionOutput output = tx.getOutputs().get(match.outputIndex());
+ BlockTransaction blkTx = entry.getValue();
+ Set<BlockTransactionHashIndex> initialTxos = new TreeSet<>();
+ initialTxos.add(new BlockTransactionHashIndex(txid, blkTx.getHeight(), blkTx.getDate(), blkTx.getFee(), match.outputIndex(), output.getValue()));
+ newNode.updateTransactionOutputs(wallet, initialTxos);
+
if(purpose == KeyPurpose.CHANGE) {
changeNextIndex++;
} else {
@@ -1609,23 +1626,59 @@ public class ElectrumServer {
//batch (either fetched here or pulled in from broadcastedTransactions), so calculateNodeHistory
//runs for them in the same atomic pass and sets spentBy on the spent TXOs.
Map<HashIndex, WalletNode> walletTxoNodes = new HashMap<>();
- for(Map.Entry<BlockTransactionHashIndex, WalletNode> e : wallet.getWalletTxos().entrySet()) {
- walletTxoNodes.put(new HashIndex(e.getKey().getHash(), e.getKey().getIndex()), e.getValue());
+ Map<HashIndex, BlockTransactionHashIndex> walletTxoIndex = new HashMap<>();
+ for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : wallet.getWalletTxos().entrySet()) {
+ HashIndex hashIndex = new HashIndex(entry.getKey().getHash(), entry.getKey().getIndex());
+ walletTxoNodes.put(hashIndex, entry.getValue());
+ walletTxoIndex.put(hashIndex, entry.getKey());
}
Set<WalletNode> spentInputNodes = new LinkedHashSet<>();
- for(Map.Entry<Sha256Hash, BlockTransaction> e : transactionMap.entrySet()) {
- if(alreadyInWallet.contains(e.getKey())) {
+ Map<WalletNode, Map<HashIndex, BlockTransactionHashIndex>> nodeSpendsByHashIndex = new LinkedHashMap<>();
+ for(Map.Entry<Sha256Hash, BlockTransaction> entry : transactionMap.entrySet()) {
+ if(alreadyInWallet.contains(entry.getKey())) {
continue;
}
- for(TransactionInput input : e.getValue().getTransaction().getInputs()) {
- WalletNode node = walletTxoNodes.get(new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex()));
- if(node != null) {
- spentInputNodes.add(node);
+ Sha256Hash spendingTxid = entry.getKey();
+ BlockTransaction spendingBlkTx = entry.getValue();
+ List<TransactionInput> inputs = spendingBlkTx.getTransaction().getInputs();
+ for(int inputIndex = 0; inputIndex < inputs.size(); inputIndex++) {
+ TransactionInput input = inputs.get(inputIndex);
+ HashIndex inputHashIndex = new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex());
+ WalletNode node = walletTxoNodes.get(inputHashIndex);
+ if(node == null) {
+ continue;
+ }
+ spentInputNodes.add(node);
+
+ BlockTransactionHashIndex existingTxo = walletTxoIndex.get(inputHashIndex);
+ if(existingTxo == null || existingTxo.getSpentBy() != null) {
+ //Already-spent UTXO (e.g. RBF chain) — leave the prior spentBy untouched and let
+ //calculateNodeHistory reconcile on the next refresh against server-authoritative data.
+ continue;
}
+ BlockTransactionHashIndex spendingTxi = new BlockTransactionHashIndex(spendingTxid, spendingBlkTx.getHeight(), spendingBlkTx.getDate(), spendingBlkTx.getFee(),
+ inputIndex, existingTxo.getValue());
+ nodeSpendsByHashIndex.computeIfAbsent(node, n -> new HashMap<>()).put(inputHashIndex, spendingTxi);
}
}
affectedNodes.addAll(spentInputNodes);
+ //Apply the spentBy pre-populates via TreeSet rebuild for each affected node.
+ for(Map.Entry<WalletNode, Map<HashIndex, BlockTransactionHashIndex>> entry : nodeSpendsByHashIndex.entrySet()) {
+ WalletNode node = entry.getKey();
+ Map<HashIndex, BlockTransactionHashIndex> spendsByHashIndex = entry.getValue();
+ Set<BlockTransactionHashIndex> rebuilt = new TreeSet<>();
+ for(BlockTransactionHashIndex txo : new ArrayList<>(node.getTransactionOutputs())) {
+ BlockTransactionHashIndex spendingTxi = spendsByHashIndex.get(new HashIndex(txo.getHash(), txo.getIndex()));
+ if(spendingTxi != null && txo.getSpentBy() == null) {
+ rebuilt.add(new BlockTransactionHashIndex(txo.getHash(), txo.getHeight(), txo.getDate(), txo.getFee(), txo.getIndex(), txo.getValue(), spendingTxi));
+ } else {
+ rebuilt.add(txo);
+ }
+ }
+ node.updateTransactionOutputs(wallet, rebuilt);
+ }
+
return affectedNodes;
}
Why this scored 23/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.