improve wallet update behaviour for silent payment self-sends
What changed, and why it matters
This commit fixes how Sparrow Wallet updates its internal records when a user sends bitcoin to themselves using a 'silent payment' address. Previously, the wallet could fail to mark the coins being spent as spent, or could show an incorrect balance, because the wallet's update logic did not properly handle transactions discovered through the silent-payment channel. The patch makes the wallet consider both newly discovered silent-payment outputs and the inputs they spend in a single atomic update, and it also improves how broadcast transactions are tracked before they are sent to the network.
Treat this as a bug-fix commit with moderate wallet-integrity relevance. Users relying on silent payments should upgrade to a release containing this commit. Reviewers should verify that the new affectedNodes logic correctly covers all spent inputs and that the eager broadcast rollback does not introduce any race conditions with concurrent wallet refreshes.
Security signals we found
Incorrect wallet state update for self-spends via silent payments could leave spent TXOs unmarked (UTXO double-count / balance inflation).
Eager population of broadcastedTransactions with rollback on failure reduces window for inconsistent local state after broadcast failure.
Fee handling now falls back to cached fee when server reference lacks fee data, improving consistency of stored transaction metadata.
Silent payment batch processing now updates wallet transactions and marks spent-input nodes atomically.
Evidence from the diff
The change is localized to ElectrumServer.java. It broadens optimistic script-hash status matching from only broadcastedTransactions to also include mempool-persisted wallet transactions, precomputes a predicted height for those candidates, and preserves cached fees when server references lack fee data. It also eagerly populates broadcastedTransactions before broadcasting and rolls back on failure. The core silent-payment fix is in processSilentPaymentBatch: it now tracks which batch txids were already in the wallet, falls back to broadcastedTransactions before fetching, updates the wallet with fetched transactions, and returns ‘affectedNodes’ that include both newly discovered silent-payment nodes and wallet nodes whose UTXOs are spent by newly introduced transactions. The caller then clears the retrieved script-hash status for those affected nodes so that calculateNodeHistory runs and sets spentBy on spent TXOs.
Changed components
src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.javaSilent payment batch processing (processSilentPaymentBatch)Transaction broadcast flow (broadcastTransaction / broadcastTransactionPrivately)Wallet transaction update / UTXO spent-by trackingInspect captured patch +101 / −22
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
index a379fbc..74feec0 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -524,20 +524,41 @@ public class ElectrumServer {
}
//Optimistic optimizations from guessing the script hash status based on known information
+ Map<Sha256Hash, BlockTransaction> candidateTxs = new LinkedHashMap<>(broadcastedTransactions);
+ wallet.getTransactions().forEach((txid, blkTx) -> {
+ if(blkTx.getHeight() <= 0) {
+ candidateTxs.putIfAbsent(txid, blkTx);
+ }
+ });
+
+ //Precompute the predicted height per candidate by inspecting in-wallet parents
+ Map<Sha256Hash, Integer> candidateHeights = new HashMap<>(candidateTxs.size());
+ for(Map.Entry<Sha256Hash, BlockTransaction> e : candidateTxs.entrySet()) {
+ int predicted = 0;
+ for(TransactionInput input : e.getValue().getTransaction().getInputs()) {
+ BlockTransaction parent = wallet.getWalletTransaction(input.getOutpoint().getHash());
+ if(parent != null && parent.getHeight() <= 0) {
+ predicted = -1;
+ break;
+ }
+ }
+ candidateHeights.put(e.getKey(), predicted);
+ }
+
for(Map.Entry<WalletNode, ScriptHashTx[]> entry : nodeHashHistory.entrySet()) {
WalletNode node = entry.getKey();
String scriptHash = pathScriptHashes.get(node.getDerivationPath());
List<String> statuses = subscribedScriptHashes.get(scriptHash);
if(statuses != null && !statuses.isEmpty()) {
- //Optimize for new transactions that have been recently broadcasted
- for(Sha256Hash txid : broadcastedTransactions.keySet()) {
- BlockTransaction blkTx = broadcastedTransactions.get(txid);
+ //Optimize for txs that are already known (broadcasted or mempool-persisted)
+ for(Sha256Hash txid : candidateTxs.keySet()) {
+ BlockTransaction blkTx = candidateTxs.get(txid);
if(blkTx.getTransaction().getOutputs().stream().map(ElectrumServer::getScriptHash).anyMatch(scriptHash::equals) ||
blkTx.getTransaction().getInputs().stream().map(txInput -> getPrevOutput(wallet, txInput))
.filter(Objects::nonNull).map(ElectrumServer::getScriptHash).anyMatch(scriptHash::equals)) {
List<ScriptHashTx> scriptHashTxes = new ArrayList<>(getScriptHashes(scriptHash, node));
- scriptHashTxes.add(new ScriptHashTx(0, txid.toString(), blkTx.getFee() == null ? 0 : blkTx.getFee()));
+ scriptHashTxes.add(new ScriptHashTx(candidateHeights.get(txid), txid.toString(), blkTx.getFee() == null ? 0 : blkTx.getFee()));
String status = getScriptHashStatus(scriptHashTxes);
if(Objects.equals(status, statuses.getLast())) {
@@ -741,7 +762,7 @@ public class ElectrumServer {
BlockTransactionHash reference = entry.getKey();
BlockTransaction blockTransaction = wallet.getWalletTransaction(reference.getHash());
if(blockTransaction != null) {
- if(reference.getHeight() == blockTransaction.getHeight()) {
+ if(reference.getHeight() == blockTransaction.getHeight() && (reference.getFee() == null || blockTransaction.getFee() != null)) {
iter.remove();
} else {
entry.setValue(blockTransaction.getTransaction());
@@ -866,7 +887,14 @@ public class ElectrumServer {
blockDate = blockHeader.getTimeAsDate();
}
- BlockTransaction blockchainTransaction = new BlockTransaction(reference.getHash(), reference.getHeight(), blockDate, reference.getFee(), transaction);
+ Long fee = reference.getFee();
+ if(fee == null) {
+ BlockTransaction cached = wallet.getWalletTransaction(reference.getHash());
+ if(cached != null && cached.getFee() != null) {
+ fee = cached.getFee();
+ }
+ }
+ BlockTransaction blockchainTransaction = new BlockTransaction(reference.getHash(), reference.getHeight(), blockDate, fee, transaction);
transactionMap.put(reference.getHash(), blockchainTransaction);
checkReferences.remove(reference);
@@ -1176,13 +1204,21 @@ public class ElectrumServer {
}
public Sha256Hash broadcastTransaction(Transaction transaction, Long fee) throws ServerException {
- Sha256Hash txid = broadcastTransactionPrivately(transaction);
- if(txid != null) {
- BlockTransaction blkTx = new BlockTransaction(txid, 0, null, fee, transaction);
- broadcastedTransactions.put(txid, blkTx);
- }
+ //Eagerly populate broadcastedTransactions before broadcasting and roll back on broadcast failure
+ Sha256Hash txid = transaction.getTxId();
+ BlockTransaction blkTx = new BlockTransaction(txid, 0, null, fee, transaction);
+ broadcastedTransactions.put(txid, blkTx);
- return txid;
+ try {
+ Sha256Hash broadcastTxid = broadcastTransactionPrivately(transaction);
+ if(broadcastTxid == null) {
+ broadcastedTransactions.remove(txid);
+ }
+ return broadcastTxid;
+ } catch(Exception e) {
+ broadcastedTransactions.remove(txid);
+ throw e;
+ }
}
public Sha256Hash broadcastTransactionPrivately(Transaction transaction) throws ServerException {
@@ -1191,14 +1227,14 @@ public class ElectrumServer {
List<BroadcastSource> broadcastSources = Arrays.stream(BroadcastSource.values()).filter(src -> src.getSupportedNetworks().contains(Network.get())).collect(Collectors.toList());
Sha256Hash txid = null;
for(int i = 1; !broadcastSources.isEmpty(); i++) {
+ BroadcastSource broadcastSource = broadcastSources.remove(new Random().nextInt(broadcastSources.size()));
try {
- BroadcastSource broadcastSource = broadcastSources.remove(new Random().nextInt(broadcastSources.size()));
txid = broadcastSource.broadcastTransaction(transaction);
if(Network.get() != Network.MAINNET || i >= MINIMUM_BROADCASTS || broadcastSources.isEmpty()) {
return txid;
}
} catch(BroadcastSource.BroadcastException e) {
- //ignore, already logged
+ log.error("Could not post transaction via " + broadcastSource.getName(), e);
}
}
@@ -1504,27 +1540,39 @@ public class ElectrumServer {
Map<BlockTransactionHash, Transaction> referencesToFetch = new TreeMap<>();
Map<Sha256Hash, BlockTransaction> transactionMap = new HashMap<>();
Map<Sha256Hash, byte[]> tweakMap = new HashMap<>();
+ //Track which batch txids the wallet already had. Everything else in transactionMap (via
+ //broadcastedTransactions or via the fetch below) is newly introduced to the wallet by this
+ //batch and needs its spent-input nodes identified.
+ Set<Sha256Hash> alreadyInWallet = new HashSet<>();
for(SilentPaymentsTx entry : entries) {
Sha256Hash txid = Sha256Hash.wrap(entry.tx_hash);
tweakMap.putIfAbsent(txid, Utils.hexToBytes(entry.tweak_key));
BlockTransaction existing = wallet.getWalletTransaction(txid);
if(existing != null) {
transactionMap.put(txid, existing);
+ alreadyInWallet.add(txid);
} else {
- referencesToFetch.put(new BlockTransaction(txid, entry.height, null, null, null), null);
+ existing = broadcastedTransactions.get(txid);
+ if(existing != null) {
+ transactionMap.put(txid, existing);
+ } else {
+ referencesToFetch.put(new BlockTransaction(txid, entry.height, null, null, null), null);
+ }
}
}
if(!referencesToFetch.isEmpty()) {
Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(wallet, referencesToFetch.keySet());
- transactionMap.putAll(getTransactions(wallet, referencesToFetch, blockHeaderMap));
+ Map<Sha256Hash, BlockTransaction> fetched = getTransactions(wallet, referencesToFetch, blockHeaderMap);
+ transactionMap.putAll(fetched);
+ wallet.updateTransactions(fetched);
}
ECKey scanPriv = wallet.getSilentPaymentScanAddress().getScanKey();
ECKey spendPub = wallet.getSilentPaymentScanAddress().getSpendKey();
Map<Address, WalletNode> walletAddresses = wallet.getWalletAddresses();
- Set<WalletNode> newNodes = new LinkedHashSet<>();
+ Set<WalletNode> affectedNodes = new LinkedHashSet<>();
int receiveNextIndex = nextIndex(wallet.getNode(KeyPurpose.RECEIVE));
int changeNextIndex = nextIndex(wallet.getNode(KeyPurpose.CHANGE));
@@ -1543,7 +1591,7 @@ public class ElectrumServer {
int newIndex = purpose == KeyPurpose.CHANGE ? changeNextIndex : receiveNextIndex;
WalletNode newNode = createNodeForMatch(wallet, match, walletAddresses, purpose, newIndex);
if(newNode != null) {
- newNodes.add(newNode);
+ affectedNodes.add(newNode);
walletAddresses.put(wallet.getAddress(newNode), newNode);
if(purpose == KeyPurpose.CHANGE) {
changeNextIndex++;
@@ -1557,7 +1605,28 @@ public class ElectrumServer {
}
}
- return newNodes;
+ //Include wallet nodes whose UTXOs are spent by any tx newly introduced to the wallet by this
+ //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());
+ }
+ Set<WalletNode> spentInputNodes = new LinkedHashSet<>();
+ for(Map.Entry<Sha256Hash, BlockTransaction> e : transactionMap.entrySet()) {
+ if(alreadyInWallet.contains(e.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);
+ }
+ }
+ }
+ affectedNodes.addAll(spentInputNodes);
+
+ return affectedNodes;
}
private static int nextIndex(WalletNode purposeNode) {
@@ -2085,10 +2154,20 @@ public class ElectrumServer {
try {
ElectrumServer electrumServer = new ElectrumServer();
List<SilentPaymentsTx> entries = electrumServer.getSilentPaymentHistory(scanAddress);
- Set<WalletNode> newNodes = electrumServer.processSilentPaymentBatch(wallet, entries);
+ Set<WalletNode> affectedNodes = electrumServer.processSilentPaymentBatch(wallet, entries);
+
+ //Force affected nodes to be marked in subscribeWalletNodes regardless of whether
+ //their scripthash status push has arrived yet. Without this, a spent-input node
+ //whose push lags the SP-discovery channel won't be marked (its server-reported
+ //status still matches retrievedScriptHashes), and calculateNodeHistory won't run
+ //for it in this pass — leaving spentBy unset on the spent TXO.
+ for(WalletNode node : affectedNodes) {
+ clearRetrievedScriptHash(getScriptHash(node));
+ }
- //First refresh (acquired): fetch all nodes to re-subscribe scripthashes the server forgot. Live delta: only the new ones.
- Set<WalletNode> nodesToFetch = acquired ? null : newNodes;
+ //First refresh (acquired): fetch all nodes to re-subscribe scripthashes the server forgot.
+ //Live delta: only the affected ones (newly-discovered SP nodes + nodes spent by the batch).
+ Set<WalletNode> nodesToFetch = acquired ? null : affectedNodes;
if(nodesToFetch != null && nodesToFetch.isEmpty()) {
return true;
}
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.