From 60d4cce15bab5741a70438f28fbfb285684796bf Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Wed, 13 May 2026 11:29:30 +0200 Subject: [PATCH] pre-populate sp self-spend node outputs to avoid partial first notification --- .../sparrow/net/ElectrumServer.java | 69 ++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java index 74feec0a..c1187a39 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 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 walletTxoNodes = new HashMap<>(); - for(Map.Entry e : wallet.getWalletTxos().entrySet()) { - walletTxoNodes.put(new HashIndex(e.getKey().getHash(), e.getKey().getIndex()), e.getValue()); + Map walletTxoIndex = new HashMap<>(); + for(Map.Entry 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 spentInputNodes = new LinkedHashSet<>(); - for(Map.Entry e : transactionMap.entrySet()) { - if(alreadyInWallet.contains(e.getKey())) { + Map> nodeSpendsByHashIndex = new LinkedHashMap<>(); + for(Map.Entry 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 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> entry : nodeSpendsByHashIndex.entrySet()) { + WalletNode node = entry.getKey(); + Map spendsByHashIndex = entry.getValue(); + Set 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; }