What changed, and why it matters
This commit adds support for loading and refreshing 'silent payments' (a newer Bitcoin privacy feature) wallets in Sparrow. It introduces server subscription management, scan caching, and UI wiring. There is no direct evidence in the commit of a security vulnerability, but the new code handles private scan keys and network state, so correctness matters. The change is large and touches concurrency, making subtle bugs possible, but nothing in the diff clearly enables theft, remote code execution, or data leakage.
Treat as a feature commit, not a security patch. Reviewers should focus on: (1) correct lock usage in SilentPaymentsScanCache to avoid deadlocks or missed signals, (2) ensuring scan private key hex is zeroed/cleared after RPC use, (3) verifying that releaseSilentPaymentSubscription is always paired with holdSilentPaymentSubscription on failure paths, (4) confirming that spSubscriptionHeld and spScanInProgress flags are only mutated on the JavaFX thread to avoid races. No immediate user action is required.
Security signals we found
New network subscription lifecycle for silent payments with refcounting and concurrent access
Private scan key is serialized to hex and sent to Electrum server via subscribeSilentPayments
Concurrency primitives (ReentrantLock, Condition) used for scan cache state
Potential race: spSubscriptionHeld flag is reset in multiple places including connection close and history clear
No input validation visible for SilentPaymentsSubscription response fields beyond start_height comparison
Large refactor of WalletForm.refreshHistory with new SP-specific branch
Evidence from the diff
The commit implements SP (BIP352 silent payments) wallet loading. Key additions: ElectrumServer.holdSilentPaymentSubscription/releaseSilentPaymentSubscription with refcounting, SilentPaymentsScanCache with ReentrantLock/Condition state machine, SilentPaymentScanService and SilentPaymentsUnsubscribeService, SubscriptionService notification handling split into SilentPaymentsScanProgressEvent and SilentPaymentsHistoryUpdatedEvent, and UI/address derivation changes switching from keystore-level to wallet-level silentPaymentScanAddress. The diff also clears birthHeight when birthDate is changed and propagates birthHeight in settings. No explicit security fix or vulnerability is described in the commit message or code comments.
Changed components
ElectrumServerSilentPaymentsScanCacheSubscriptionServiceWalletFormAppServicesBatchedElectrumServerRpc / SimpleElectrumServerRpcUI controllers (ReceiveController, PaymentController, AdvancedController, SettingsController, terminal dialogs)Inspect captured patch +814 / −132
diff --git a/src/main/java/com/sparrowwallet/sparrow/AppServices.java b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
index b5f2df9..5bd3c7e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/AppServices.java
+++ b/src/main/java/com/sparrowwallet/sparrow/AppServices.java
@@ -851,6 +851,10 @@ public class AppServices {
public static void clearTransactionHistoryCache(Wallet wallet) {
ElectrumServer.clearRetrievedScriptHashes(wallet);
+ if(wallet.getPolicyType() == PolicyType.SINGLE_SP && wallet.isValid()) {
+ ElectrumServer.releaseSilentPaymentSubscription(wallet.getSilentPaymentScanAddress());
+ }
+
for(Wallet childWallet : wallet.getChildWallets()) {
if(childWallet.isNested()) {
AppServices.clearTransactionHistoryCache(childWallet);
@@ -1465,4 +1469,15 @@ public class AppServices {
onlineProperty.set(true);
}
}
+
+ @Subscribe
+ public void silentPaymentsUnsubscribe(SilentPaymentsUnsubscribeEvent event) {
+ if(isConnected()) {
+ ElectrumServer.SilentPaymentsUnsubscribeService unsubscribeService = new ElectrumServer.SilentPaymentsUnsubscribeService(event.getScanAddress());
+ unsubscribeService.setOnFailed(workerStateEvent -> {
+ log.warn("Failed to unsubscribe silent payments for " + event.getScanAddress().getAddress(), workerStateEvent.getSource().getException());
+ });
+ unsubscribeService.start();
+ }
+ }
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java b/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java
index 7e72785..6688c43 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/CoinTreeTable.java
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.BitcoinUnit;
+import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.SortDirection;
import com.sparrowwallet.drongo.wallet.TableType;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -122,7 +123,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
StackPane stackPane = new StackPane();
stackPane.getChildren().add(AppServices.isConnecting() ? new Label("Loading transactions...") : new Label("No transactions"));
- if(Config.get().getServerType() == ServerType.BITCOIN_CORE && !AppServices.isConnecting()) {
+ if((Config.get().getServerType() == ServerType.BITCOIN_CORE || wallet.getPolicyType() == PolicyType.SINGLE_SP) && !AppServices.isConnecting()) {
Hyperlink hyperlink = new Hyperlink();
hyperlink.setTranslateY(30);
hyperlink.setOnAction(event -> {
@@ -133,6 +134,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Wallet pastWallet = wallet.copy();
wallet.setBirthDate(optDate.get());
+ wallet.setBirthHeight(null);
//Trigger background save of birthdate
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
index 356e263..bf43bf1 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java
@@ -336,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
if(cancelTransaction) {
Payment existing = payments.get(0);
Payment payment = transactionEntry.getWallet().getPolicyType() == PolicyType.SINGLE_SP ?
- new SilentPayment(transactionEntry.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(),
+ new SilentPayment(transactionEntry.getWallet().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(),
existing.getLabel(), existing.getAmount(), true) :
new Payment(transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress(), existing.getLabel(), existing.getAmount(), true);
payments.clear();
@@ -400,7 +400,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
Payment payment = transactionEntry.getWallet().getPolicyType() == PolicyType.SINGLE_SP ?
- new SilentPayment(transactionEntry.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(),
+ new SilentPayment(transactionEntry.getWallet().getSilentPaymentScanAddress().getChangeAddress().getSilentPaymentAddress(),
label, inputTotal, true) :
new Payment(transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getAddress(), label, inputTotal, true);
diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
index 87cbc34..33f9fd8 100644
--- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java
@@ -218,7 +218,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
toWallet.valueProperty().addListener((observable, oldValue, selectedWallet) -> {
if(selectedWallet != null) {
if(selectedWallet.getPolicyType() == PolicyType.SINGLE_SP) {
- toAddress.setText(selectedWallet.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress());
+ toAddress.setText(selectedWallet.getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress());
} else {
toAddress.setText(selectedWallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString());
}
@@ -228,7 +228,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
keyScriptType.setValue(ScriptType.P2PKH);
if(wallet != null) {
if(wallet.getPolicyType() == PolicyType.SINGLE_SP) {
- toAddress.setText(wallet.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress());
+ toAddress.setText(wallet.getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress());
} else {
toAddress.setText(wallet.getFreshNode(KeyPurpose.RECEIVE).getAddress().toString());
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsHistoryUpdatedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsHistoryUpdatedEvent.java
new file mode 100644
index 0000000..b6f3587
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsHistoryUpdatedEvent.java
@@ -0,0 +1,22 @@
+package com.sparrowwallet.sparrow.event;
+
+/**
+ * Posted by SubscriptionService on a live silent-payments delta — i.e. a progress = 1.0 notification
+ * that arrives after the historical scan has already completed. Carries the SP address only;
+ * consumers re-call ElectrumServer.getSilentPaymentHistory(scanAddress, neededStart) to get the
+ * full cache and apply their own wallet.getWalletTransaction(txid) filter for "what's new for me".
+ * <p>
+ * The first historical-scan-complete notification is consumed by the blocking getSilentPaymentHistory
+ * call's latch and does NOT post this event.
+ */
+public class SilentPaymentsHistoryUpdatedEvent {
+ private final String spAddress;
+
+ public SilentPaymentsHistoryUpdatedEvent(String spAddress) {
+ this.spAddress = spAddress;
+ }
+
+ public String getSpAddress() {
+ return spAddress;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsNotificationEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsNotificationEvent.java
deleted file mode 100644
index 71ece42..0000000
--- a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsNotificationEvent.java
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.sparrowwallet.sparrow.event;
-
-import com.sparrowwallet.sparrow.net.SilentPaymentsSubscription;
-import com.sparrowwallet.sparrow.net.SilentPaymentsTx;
-
-import java.util.List;
-
-/**
- * Posted when a blockchain.silentpayments.subscribe notification is received from an Electrum server.
- * Carries the subscription payload, the progress (with 1.0 marking the historical-scan-complete
- * moment per BIP352), and the list of silent-payment transactions in this batch.
- * <p>
- * The event holds no wallet reference; consumers (typically a WalletForm) match
- * {@link SilentPaymentsSubscription#address} against their wallet's silent-payment address and
- * ignore the event otherwise. This mirrors how WalletNodeHistoryChangedEvent is consumed and avoids
- * pinning closed wallets through static event-router state.
- */
-public class SilentPaymentsNotificationEvent {
- private final SilentPaymentsSubscription subscription;
- private final double progress;
- private final List<SilentPaymentsTx> history;
-
- public SilentPaymentsNotificationEvent(SilentPaymentsSubscription subscription, double progress, List<SilentPaymentsTx> history) {
- this.subscription = subscription;
- this.progress = progress;
- this.history = history;
- }
-
- public SilentPaymentsSubscription getSubscription() {
- return subscription;
- }
-
- public double getProgress() {
- return progress;
- }
-
- public List<SilentPaymentsTx> getHistory() {
- return history;
- }
-}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsScanProgressEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsScanProgressEvent.java
new file mode 100644
index 0000000..2195e19
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsScanProgressEvent.java
@@ -0,0 +1,24 @@
+package com.sparrowwallet.sparrow.event;
+
+/**
+ * Posted by SubscriptionService on every blockchain.silentpayments.subscribe notification.
+ * Carries the SP address and the scan's progress (0.0–1.0). WalletForm consumers use this for
+ * status UI; the gating decision (whether to display) is per-wallet runtime state, not encoded here.
+ */
+public class SilentPaymentsScanProgressEvent {
+ private final String spAddress;
+ private final double progress;
+
+ public SilentPaymentsScanProgressEvent(String spAddress, double progress) {
+ this.spAddress = spAddress;
+ this.progress = progress;
+ }
+
+ public String getSpAddress() {
+ return spAddress;
+ }
+
+ public double getProgress() {
+ return progress;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsUnsubscribeEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsUnsubscribeEvent.java
new file mode 100644
index 0000000..de79bc2
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/event/SilentPaymentsUnsubscribeEvent.java
@@ -0,0 +1,21 @@
+package com.sparrowwallet.sparrow.event;
+
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
+
+/**
+ * Posted by ElectrumServer.releaseSilentPaymentSubscription when the refcount on a silent-payments
+ * subscription reaches zero. The subscriber starts a background SilentPaymentsUnsubscribeService to
+ * issue the actual unsubscribe RPC, since releaseSilentPaymentSubscription is reached from JFX-thread
+ * paths (wallet close, refresh, history clear) where blocking on a network call is not acceptable.
+ */
+public class SilentPaymentsUnsubscribeEvent {
+ private final SilentPaymentScanAddress scanAddress;
+
+ public SilentPaymentsUnsubscribeEvent(SilentPaymentScanAddress scanAddress) {
+ this.scanAddress = scanAddress;
+ }
+
+ public SilentPaymentScanAddress getScanAddress() {
+ return scanAddress;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java
index 6725fd4..1f3b851 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/BatchedElectrumServerRpc.java
@@ -183,11 +183,11 @@ public class BatchedElectrumServerRpc implements ElectrumServerRpc {
}
@Override
- public String subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) {
+ public SilentPaymentsSubscription subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) {
JsonRpcClient client = new JsonRpcClient(transport);
try {
- return new RetryLogic<String>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
- client.createRequest().returnAs(String.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute());
+ return new RetryLogic<SilentPaymentsSubscription>(DEFAULT_MAX_ATTEMPTS, RETRY_DELAY_SECS, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
+ client.createRequest().returnAs(SilentPaymentsSubscription.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute());
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to subscribe to silent payments for wallet " + wallet.getName(), e);
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
index 1acc7fd..f1af4ef 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServer.java
@@ -10,6 +10,11 @@ import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.*;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.silentpayments.InvalidSilentPaymentException;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanMatch;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentUtils;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.BlockSummary;
@@ -60,6 +65,8 @@ public class ElectrumServer {
private static final int MINIMUM_BROADCASTS = 2;
+ private static final int[] NO_LABELS = new int[0];
+
public static final BlockTransaction UNFETCHABLE_BLOCK_TRANSACTION = new BlockTransaction(Sha256Hash.ZERO_HASH, 0, null, null, null);
private static CloseableTransport transport;
@@ -80,6 +87,8 @@ public class ElectrumServer {
private static final Map<Integer, WalletSyncLock> walletSyncLocks = Collections.synchronizedMap(new HashMap<>());
+ private static final Map<String, SilentPaymentsScanCache> spScanCaches = new ConcurrentHashMap<>();
+
private final static Map<String, Integer> subscribedRecent = new ConcurrentHashMap<>();
private final static Map<String, String> broadcastRecent = new ConcurrentHashMap<>();
@@ -199,6 +208,8 @@ public class ElectrumServer {
public static synchronized void closeActiveConnection() throws ServerException {
if(transport != null) {
+ cancelSilentPaymentScans();
+ spScanCaches.clear();
closeConnection(transport);
transport = null;
}
@@ -1322,6 +1333,248 @@ public class ElectrumServer {
}
}
+ static SilentPaymentsScanCache getScanCache(String spAddress) {
+ return spScanCaches.get(spAddress);
+ }
+
+ public static boolean hasSilentPaymentsCache(SilentPaymentScanAddress scanAddress) {
+ return spScanCaches.containsKey(scanAddress.getAddress());
+ }
+
+ private static void cancelSilentPaymentScans() {
+ for(SilentPaymentsScanCache cache : spScanCaches.values()) {
+ cache.lock();
+ try {
+ cache.cancel();
+ } finally {
+ cache.unlock();
+ }
+ }
+ }
+
+ /**
+ * Holds a silent-payment subscription for the given scan address. Increments the per-cache refcount,
+ * issuing a subscribe RPC the first time the cache is established and re-issuing if the needed start
+ * is lower than the current subscription's start. On RPC failure the refcount is rolled back.
+ * Callers must pair every successful hold with a matching call.
+ */
+ public static void holdSilentPaymentSubscription(Wallet wallet, SilentPaymentScanAddress scanAddress, int neededStart) throws ServerException {
+ requireSilentPaymentsSupport();
+ String spAddress = scanAddress.getAddress();
+ SilentPaymentsScanCache cache = spScanCaches.computeIfAbsent(spAddress, k -> new SilentPaymentsScanCache());
+
+ boolean needSubscribe;
+ SilentPaymentsScanCache.Snapshot rollbackSnapshot = null;
+ cache.lock();
+ try {
+ boolean isFirstCaller = cache.incrementRefCount() == 1;
+ //If a concurrent caller is currently establishing the subscription, wait for serverStart to be
+ //captured before making our widening decision. awaitSubscriptionComplete() releases the cache
+ //lock during the wait, allowing notification handlers to proceed (avoids deadlock with
+ //TcpTransport's read thread).
+ while(cache.hasMultipleHolders() && cache.getServerStart() == null && cache.isScanning()) {
+ try {
+ cache.awaitSubscriptionComplete();
+ } catch(InterruptedException e) {
+ Thread.currentThread().interrupt();
+ if(cache.decrementRefCount()) {
+ spScanCaches.remove(spAddress);
+ }
+ throw new ServerException("Interrupted waiting for silent payments subscription to establish", e);
+ }
+ }
+
+ if(cache.isCancelled()) {
+ //First caller's subscribe failed (or scan was cancelled) — propagate.
+ if(cache.decrementRefCount()) {
+ spScanCaches.remove(spAddress);
+ }
+ throw new ServerException("Silent payments subscription failed for " + spAddress);
+ }
+
+ if(isFirstCaller) {
+ //Cache was just created by computeIfAbsent above, with all defaults. Establish the subscription.
+ needSubscribe = true;
+ } else if(needsWiderCoverage(neededStart, cache.getServerStart())) {
+ //New caller wants earlier history than current coverage; widen and trigger a rescan.
+ rollbackSnapshot = cache.captureSnapshot();
+ cache.restartScan();
+ needSubscribe = true;
+ } else {
+ needSubscribe = false;
+ }
+ } finally {
+ cache.unlock();
+ }
+
+ if(needSubscribe) {
+ try {
+ String scanPrivHex = Utils.bytesToHex(scanAddress.getScanKey().getPrivKeyBytes());
+ String spendPubHex = Utils.bytesToHex(scanAddress.getSpendKey().getPubKey(true));
+ SilentPaymentsSubscription response = electrumServerRpc.subscribeSilentPayments(getTransport(), wallet, scanPrivHex, spendPubHex, neededStart, NO_LABELS);
+ cache.lock();
+ try {
+ cache.setServerStart(response.start_height);
+ } finally {
+ cache.unlock();
+ }
+ } catch(Exception e) {
+ cache.lock();
+ try {
+ if(rollbackSnapshot != null && cache.hasMultipleHolders()) {
+ cache.restoreFromSnapshot(rollbackSnapshot);
+ } else {
+ cache.cancel();
+ }
+ if(cache.decrementRefCount()) {
+ spScanCaches.remove(spAddress);
+ }
+ } finally {
+ cache.unlock();
+ }
+ throw e;
+ }
+ }
+ }
+
+ private static boolean needsWiderCoverage(int neededStart, int serverStart) {
+ boolean neededIsTimestamp = neededStart >= Transaction.MAX_BLOCK_LOCKTIME;
+ boolean serverIsTimestamp = serverStart >= Transaction.MAX_BLOCK_LOCKTIME;
+ if(neededIsTimestamp != serverIsTimestamp) {
+ return neededIsTimestamp;
+ }
+ return neededStart < serverStart;
+ }
+
+ public List<SilentPaymentsTx> getSilentPaymentHistory(SilentPaymentScanAddress scanAddress) throws ServerException {
+ String spAddress = scanAddress.getAddress();
+ SilentPaymentsScanCache cache = spScanCaches.get(spAddress);
+ if(cache == null) {
+ throw new IllegalStateException("No silent payments subscription is held for " + spAddress);
+ }
+
+ cache.lock();
+ try {
+ while(cache.isScanning()) {
+ try {
+ cache.awaitScanComplete();
+ } catch(InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ServerException("Interrupted waiting for silent payments scan to complete", e);
+ }
+ }
+ if(cache.isCancelled()) {
+ throw new ServerException("Silent payments scan was cancelled for " + spAddress);
+ }
+ return cache.snapshotEntries();
+ } finally {
+ cache.unlock();
+ }
+ }
+
+ public static void releaseSilentPaymentSubscription(SilentPaymentScanAddress scanAddress) {
+ String spAddress = scanAddress.getAddress();
+ SilentPaymentsScanCache cache = spScanCaches.get(spAddress);
+ if(cache == null) {
+ return;
+ }
+
+ boolean unsubscribe;
+ cache.lock();
+ try {
+ unsubscribe = cache.decrementRefCount();
+ if(unsubscribe) {
+ cache.cancel();
+ spScanCaches.remove(spAddress);
+ }
+ } finally {
+ cache.unlock();
+ }
+
+ if(unsubscribe) {
+ Platform.runLater(() -> EventManager.get().post(new SilentPaymentsUnsubscribeEvent(scanAddress)));
+ }
+ }
+
+ public Set<WalletNode> processSilentPaymentBatch(Wallet wallet, List<SilentPaymentsTx> entries) throws ServerException {
+ if(entries.isEmpty()) {
+ return Collections.emptySet();
+ }
+
+ Map<BlockTransactionHash, Transaction> references = new TreeMap<>();
+ Map<Sha256Hash, byte[]> tweakMap = new HashMap<>();
+ for(SilentPaymentsTx entry : entries) {
+ Sha256Hash txid = Sha256Hash.wrap(entry.tx_hash);
+ tweakMap.putIfAbsent(txid, Utils.hexToBytes(entry.tweak_key));
+ if(wallet.getWalletTransaction(txid) == null) {
+ references.put(new BlockTransaction(txid, entry.height, null, null, null), null);
+ }
+ }
+ if(references.isEmpty()) {
+ return Collections.emptySet();
+ }
+
+ Map<Integer, BlockHeader> blockHeaderMap = getBlockHeaders(wallet, references.keySet());
+ Map<Sha256Hash, BlockTransaction> transactionMap = getTransactions(wallet, references, blockHeaderMap);
+
+ ECKey scanPriv = wallet.getSilentPaymentScanAddress().getScanKey();
+ ECKey spendPub = wallet.getSilentPaymentScanAddress().getSpendKey();
+
+ Map<Address, WalletNode> walletAddresses = wallet.getWalletAddresses();
+ Set<WalletNode> newNodes = new LinkedHashSet<>();
+
+ int receiveNextIndex = nextIndex(wallet.getNode(KeyPurpose.RECEIVE));
+ int changeNextIndex = nextIndex(wallet.getNode(KeyPurpose.CHANGE));
+
+ for(Map.Entry<Sha256Hash, BlockTransaction> entry : transactionMap.entrySet()) {
+ Sha256Hash txid = entry.getKey();
+ Transaction tx = entry.getValue().getTransaction();
+ byte[] tweakKey = tweakMap.get(txid);
+ if(tweakKey == null) {
+ continue;
+ }
+ try {
+ List<SilentPaymentScanMatch> matches = SilentPaymentUtils.scanTransactionOutputs(scanPriv, spendPub, Collections.emptySet(), tweakKey, tx.getOutputs());
+ for(SilentPaymentScanMatch match : matches) {
+ KeyPurpose purpose = match.labelIndex() != null && match.labelIndex() == 0 ? KeyPurpose.CHANGE : KeyPurpose.RECEIVE;
+ int newIndex = purpose == KeyPurpose.CHANGE ? changeNextIndex : receiveNextIndex;
+ WalletNode newNode = createNodeForMatch(wallet, match, walletAddresses, purpose, newIndex);
+ if(newNode != null) {
+ newNodes.add(newNode);
+ walletAddresses.put(wallet.getAddress(newNode), newNode);
+ if(purpose == KeyPurpose.CHANGE) {
+ changeNextIndex++;
+ } else {
+ receiveNextIndex++;
+ }
+ }
+ }
+ } catch(InvalidSilentPaymentException e) {
+ log.warn("Invalid silent payment tweak for tx " + txid + " — skipping", e);
+ }
+ }
+
+ return newNodes;
+ }
+
+ private static int nextIndex(WalletNode purposeNode) {
+ return purposeNode.getChildren().isEmpty() ? 0 : purposeNode.getChildren().stream().mapToInt(WalletNode::getIndex).max().getAsInt() + 1;
+ }
+
+ private WalletNode createNodeForMatch(Wallet wallet, SilentPaymentScanMatch match, Map<Address, WalletNode> walletAddresses, KeyPurpose purpose, int newIndex) {
+ WalletNode purposeNode = wallet.getNode(purpose);
+ Set<WalletNode> created = purposeNode.fillToIndex(wallet, newIndex);
+ WalletNode addressNode = created.stream().filter(n -> n.getIndex() == newIndex).findFirst().orElseThrow(() -> new IllegalStateException("fillToIndex did not create node at index " + newIndex));
+ addressNode.setSilentPaymentTweak(match.tweak());
+
+ if(walletAddresses.containsKey(wallet.getAddress(addressNode))) {
+ purposeNode.getChildren().removeAll(created);
+ return null;
+ }
+
+ return addressNode;
+ }
+
public static String getSubscribedScriptHashStatus(String scriptHash) {
List<String> existingStatuses = subscribedScriptHashes.get(scriptHash);
if(existingStatuses != null && !existingStatuses.isEmpty()) {
@@ -1798,6 +2051,80 @@ public class ElectrumServer {
}
}
+ public static class SilentPaymentScanService extends Service<Boolean> {
+ private final Wallet wallet;
+ private final SilentPaymentScanAddress scanAddress;
+ private final boolean shouldHold;
+ private final int neededStart;
+ private volatile boolean releasedHold;
+
+ public SilentPaymentScanService(Wallet wallet, boolean shouldHold, int neededStart) {
+ this.wallet = wallet;
+ this.scanAddress = wallet.getSilentPaymentScanAddress();
+ this.shouldHold = shouldHold;
+ this.neededStart = neededStart;
+ }
+
+ public boolean isReleasedHold() {
+ return releasedHold;
+ }
+
+ @Override
+ protected Task<Boolean> createTask() {
+ return new Task<>() {
+ @Override
+ protected Boolean call() throws ServerException {
+ boolean acquired = shouldHold || !ElectrumServer.hasSilentPaymentsCache(scanAddress);
+ if(acquired) {
+ ElectrumServer.holdSilentPaymentSubscription(wallet, scanAddress, neededStart);
+ }
+ try {
+ ElectrumServer electrumServer = new ElectrumServer();
+ List<SilentPaymentsTx> entries = electrumServer.getSilentPaymentHistory(scanAddress);
+ Set<WalletNode> newNodes = electrumServer.processSilentPaymentBatch(wallet, entries);
+
+ //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;
+ if(nodesToFetch != null && nodesToFetch.isEmpty()) {
+ return true;
+ }
+ return electrumServer.fetchAndCalculateHistory(wallet, null, nodesToFetch);
+ } catch(Exception e) {
+ if(acquired) {
+ ElectrumServer.releaseSilentPaymentSubscription(scanAddress);
+ releasedHold = true;
+ }
+ throw e;
+ }
+ }
+ };
+ }
+ }
+
+ public static class SilentPaymentsUnsubscribeService extends Service<Boolean> {
+ private final SilentPaymentScanAddress scanAddress;
+
+ public SilentPaymentsUnsubscribeService(SilentPaymentScanAddress scanAddress) {
+ this.scanAddress = scanAddress;
+ }
+
+ @Override
+ protected Task<Boolean> createTask() {
+ return new Task<>() {
+ @Override
+ protected Boolean call() throws ServerException {
+ if(ElectrumServer.hasSilentPaymentsCache(scanAddress)) {
+ return false;
+ }
+ String scanPrivHex = Utils.bytesToHex(scanAddress.getScanKey().getPrivKeyBytes());
+ String spendPubHex = Utils.bytesToHex(scanAddress.getSpendKey().getPubKey(true));
+ electrumServerRpc.unsubscribeSilentPayments(getTransport(), scanPrivHex, spendPubHex);
+ return true;
+ }
+ };
+ }
+ }
+
public static class TransactionMempoolService extends ScheduledService<Set<String>> {
private final Wallet wallet;
private final Sha256Hash txId;
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java
index 5c9163e..1082aad 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/ElectrumServerRpc.java
@@ -26,7 +26,7 @@ public interface ElectrumServerRpc {
Map<String, Boolean> unsubscribeScriptHashes(Transport transport, Set<String> scriptHashes);
- String subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels);
+ SilentPaymentsSubscription subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels);
String unsubscribeSilentPayments(Transport transport, String scanPrivKeyHex, String spendPubKeyHex);
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java b/src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java
new file mode 100644
index 0000000..8ecdc68
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java
@@ -0,0 +1,166 @@
+package com.sparrowwallet.sparrow.net;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+
+class SilentPaymentsScanCache {
+ private enum State { SCANNING, COMPLETED, CANCELLED }
+
+ private Integer serverStart;
+ private int refCount;
+ private State state = State.SCANNING;
+ private final List<SilentPaymentsTx> entries = new ArrayList<>();
+
+ private final ReentrantLock lock = new ReentrantLock();
+ private final Condition subscriptionComplete = lock.newCondition();
+ private final Condition scanComplete = lock.newCondition();
+
+ void lock() {
+ lock.lock();
+ }
+
+ void unlock() {
+ lock.unlock();
+ }
+
+ void awaitSubscriptionComplete() throws InterruptedException {
+ assert lock.isHeldByCurrentThread();
+ subscriptionComplete.await();
+ }
+
+ void awaitScanComplete() throws InterruptedException {
+ assert lock.isHeldByCurrentThread();
+ scanComplete.await();
+ }
+
+ boolean isScanning() {
+ return state == State.SCANNING;
+ }
+
+ boolean isCancelled() {
+ return state == State.CANCELLED;
+ }
+
+ void cancel() {
+ assert lock.isHeldByCurrentThread();
+ if(state == State.SCANNING) {
+ state = State.CANCELLED;
+ subscriptionComplete.signalAll();
+ scanComplete.signalAll();
+ }
+ }
+
+ void complete() {
+ assert lock.isHeldByCurrentThread();
+ if(state == State.SCANNING) {
+ state = State.COMPLETED;
+ scanComplete.signalAll();
+ }
+ }
+
+ /**
+ * Reset for a widening rescan: clear serverStart, clear entries, set state back to SCANNING.
+ * <p>
+ * Deliberately does <b>not</b> signal either condition. Reasoning:
+ * <ul>
+ * <li><b>subscriptionComplete</b> waiters require {@code hasMultipleHolders() && getServerStart() == null && isScanning()}.
+ * They cannot exist at this point — restartScan is reached only when {@code getServerStart() != null}
+ * (the widening branch's comparison just evaluated it), so any earlier waiters had already been signalled
+ * by {@link #setServerStart} and exited their wait loop. After restartScan clears serverStart, new waiters
+ * can arrive on subsequent holdSilentPaymentSubscription calls and will block normally; the next
+ * {@link #setServerStart} call (after the widening RPC returns) will signal them.</li>
+ * <li><b>scanComplete</b> waiters require {@code isScanning()}. After restartScan, isScanning is still true
+ * (transitioning {@code SCANNING → SCANNING} for mid-scan widening, or {@code COMPLETED → SCANNING} for
+ * post-scan widening — and in the latter case no scanComplete waiters can exist because they would have
+ * returned when the prior {@link #complete} signalled them). Existing waiters should keep waiting; they'll
+ * wake when the new scan reaches a terminal state via {@link #complete} or {@link #cancel}.</li>
+ * </ul>
+ * <b>Maintenance note:</b> if a future change adds a new wait condition that depends on state-not-being-SCANNING,
+ * serverStart-being-non-null, or entries being non-empty, this method must be updated to signal the new condition.
+ * The widening RPC's failure path is covered separately — {@link #cancel} fires both signals — so callers do not
+ * rely on restartScan having signalled.
+ */
+ void restartScan() {
+ assert lock.isHeldByCurrentThread();
+ serverStart = null;
+ entries.clear();
+ state = State.SCANNING;
+ }
+
+ Integer getServerStart() {
+ assert lock.isHeldByCurrentThread();
+ return serverStart;
+ }
+
+ void setServerStart(int height) {
+ assert lock.isHeldByCurrentThread();
+ serverStart = height;
+ subscriptionComplete.signalAll();
+ }
+
+ int incrementRefCount() {
+ assert lock.isHeldByCurrentThread();
+ return ++refCount;
+ }
+
+ boolean decrementRefCount() {
+ assert lock.isHeldByCurrentThread();
+ return --refCount <= 0;
+ }
+
+ boolean hasMultipleHolders() {
+ assert lock.isHeldByCurrentThread();
+ return refCount > 1;
+ }
+
+ void addEntries(List<SilentPaymentsTx> newEntries) {
+ assert lock.isHeldByCurrentThread();
+ entries.addAll(newEntries);
+ }
+
+ List<SilentPaymentsTx> snapshotEntries() {
+ assert lock.isHeldByCurrentThread();
+ return new ArrayList<>(entries);
+ }
+
+ /**
+ * Captures the cache's pre-widening state ({@code state}, {@code serverStart}, {@code entries}) so it
+ * can be restored if the widening RPC fails and other holders are still relying on the cache. Caller
+ * must already hold the cache lock.
+ */
+ Snapshot captureSnapshot() {
+ assert lock.isHeldByCurrentThread();
+ return new Snapshot(state, serverStart, new ArrayList<>(entries));
+ }
+
+ /**
+ * Restores the cache's state from a previously captured {@link Snapshot} and signals condition
+ * waiters whose conditions may have become re-evaluable. Used by the widening-failure recovery path
+ * to restore an in-progress scan when the widening RPC fails but other holders still depend on the cache.
+ */
+ void restoreFromSnapshot(Snapshot snapshot) {
+ assert lock.isHeldByCurrentThread();
+ state = snapshot.state;
+ serverStart = snapshot.serverStart;
+ entries.clear();
+ entries.addAll(snapshot.entries);
+ //Wake hold-side waiters who may have been blocked on serverStart==null during the failed widening.
+ //scanComplete waiters whose state-condition was unchanged during the widening don't need a signal,
+ //but signalling is harmless (they re-check isScanning() and re-await if still scanning).
+ subscriptionComplete.signalAll();
+ }
+
+ static final class Snapshot {
+ private final State state;
+ private final Integer serverStart;
+ private final List<SilentPaymentsTx> entries;
+
+ private Snapshot(State state, Integer serverStart, List<SilentPaymentsTx> entries) {
+ this.state = state;
+ this.serverStart = serverStart;
+ this.entries = entries;
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java
index 97850f9..b909e7e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/SimpleElectrumServerRpc.java
@@ -182,11 +182,11 @@ public class SimpleElectrumServerRpc implements ElectrumServerRpc {
}
@Override
- public String subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) {
+ public SilentPaymentsSubscription subscribeSilentPayments(Transport transport, Wallet wallet, String scanPrivKeyHex, String spendPubKeyHex, Object start, int[] labels) {
JsonRpcClient client = new JsonRpcClient(transport);
try {
- return new RetryLogic<String>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
- client.createRequest().returnAs(String.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute());
+ return new RetryLogic<SilentPaymentsSubscription>(MAX_RETRIES, RETRY_DELAY, List.of(IllegalStateException.class, IllegalArgumentException.class)).getResult(() ->
+ client.createRequest().returnAs(SilentPaymentsSubscription.class).method("blockchain.silentpayments.subscribe").id(idCounter.incrementAndGet()).params(scanPrivKeyHex, spendPubKeyHex, start, labels).execute());
} catch(Exception e) {
throw new ElectrumServerRpcException("Failed to subscribe to silent payments for wallet " + wallet.getName(), e);
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
index 9870ca6..4117b5b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
+++ b/src/main/java/com/sparrowwallet/sparrow/net/SubscriptionService.java
@@ -7,7 +7,8 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
import com.google.common.collect.Iterables;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.NewBlockEvent;
-import com.sparrowwallet.sparrow.event.SilentPaymentsNotificationEvent;
+import com.sparrowwallet.sparrow.event.SilentPaymentsHistoryUpdatedEvent;
+import com.sparrowwallet.sparrow.event.SilentPaymentsScanProgressEvent;
import com.sparrowwallet.sparrow.event.WalletNodeHistoryChangedEvent;
import javafx.application.Platform;
import org.slf4j.Logger;
@@ -44,6 +45,34 @@ public class SubscriptionService {
@JsonRpcMethod("blockchain.silentpayments.subscribe")
public void silentPaymentsUpdate(@JsonRpcParam("subscription") final SilentPaymentsSubscription subscription, @JsonRpcParam("progress") final double progress, @JsonRpcParam("history") final List<SilentPaymentsTx> history) {
- Platform.runLater(() -> EventManager.get().post(new SilentPaymentsNotificationEvent(subscription, progress, history)));
+ String silentPaymentAddress = subscription.address;
+ SilentPaymentsScanCache cache = ElectrumServer.getScanCache(silentPaymentAddress);
+ if(cache == null) {
+ log.trace("Received silent payments notification for unknown subscription: " + silentPaymentAddress);
+ return;
+ }
+
+ boolean justCompleted = false;
+ cache.lock();
+ try {
+ //Stale-notification filter: filter out notifications from a prior subscribe
+ Integer canonical = cache.getServerStart();
+ if(canonical == null || subscription.start_height != canonical) {
+ return;
+ }
+ cache.addEntries(history);
+ if(progress >= 1.0 && cache.isScanning()) {
+ cache.complete();
+ justCompleted = true;
+ }
+ } finally {
+ cache.unlock();
+ }
+
+ Platform.runLater(() -> EventManager.get().post(new SilentPaymentsScanProgressEvent(silentPaymentAddress, progress)));
+
+ if(progress >= 1.0 && !justCompleted && !history.isEmpty()) {
+ Platform.runLater(() -> EventManager.get().post(new SilentPaymentsHistoryUpdatedEvent(silentPaymentAddress)));
+ }
}
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java
index fd670a8..d7348a8 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/AdvancedDialog.java
@@ -66,6 +66,7 @@ public class AdvancedDialog extends WalletDialog {
try {
Date newDate = DATE_FORMAT.parse(newText);
wallet.setBirthDate(newDate);
+ wallet.setBirthHeight(null);
apply.setEnabled(true);
} catch(ParseException e) {
//ignore
diff --git a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
index 07c1ad1..951e34e 100644
--- a/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
+++ b/src/main/java/com/sparrowwallet/sparrow/terminal/wallet/ReceiveDialog.java
@@ -74,7 +74,7 @@ public class ReceiveDialog extends WalletDialog {
try {
String qrAddress;
if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
- qrAddress = getWalletForm().getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress();
+ qrAddress = getWalletForm().getWallet().getSilentPaymentScanAddress().getAddress();
} else if(currentEntry != null) {
qrAddress = currentEntry.getAddress().toString();
} else {
@@ -92,7 +92,7 @@ public class ReceiveDialog extends WalletDialog {
public void refreshAddress() {
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
if(getWalletForm().getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
- String silentPaymentAddress = getWalletForm().getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress();
+ String silentPaymentAddress = getWalletForm().getWallet().getSilentPaymentScanAddress().getAddress();
address.setText(silentPaymentAddress);
return;
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java
index 31e115e..a7809a3 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/AdvancedController.java
@@ -57,6 +57,7 @@ public class AdvancedController implements Initializable {
birthDate.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
wallet.setBirthDate(Date.from(newValue.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()));
+ wallet.setBirthHeight(null);
EventManager.get().post(new SettingsChangedEvent(wallet, SettingsChangedEvent.Type.BIRTH_DATE));
}
});
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
index a8f98c9..f2bd51a 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/PaymentController.java
@@ -316,7 +316,7 @@ public class PaymentController extends WalletFormController implements Initializ
}
} else if(newValue != null) {
if(newValue.getPolicyType() == PolicyType.SINGLE_SP) {
- address.setText(newValue.getKeystores().getFirst().getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress());
+ address.setText(newValue.getSilentPaymentScanAddress().getSilentPaymentAddress().getAddress());
} else {
List<Address> existingAddresses = getOtherAddresses();
WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE);
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
index 5f10f51..68682ed 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/ReceiveController.java
@@ -271,7 +271,7 @@ public class ReceiveController extends WalletFormController implements Initializ
public void copySilentPaymentsAddress(ActionEvent actionEvent) {
if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
ClipboardContent content = new ClipboardContent();
- content.putString(walletForm.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress());
+ content.putString(walletForm.getWallet().getSilentPaymentScanAddress().getAddress());
Clipboard.getSystemClipboard().setContent(content);
}
}
@@ -302,7 +302,7 @@ public class ReceiveController extends WalletFormController implements Initializ
public void refreshAddress() {
if(walletForm.getWallet().getPolicyType() == PolicyType.SINGLE_SP) {
- String silentPaymentAddress = walletForm.getWallet().getKeystores().getFirst().getSilentPaymentScanAddress().getAddress();
+ String silentPaymentAddress = walletForm.getWallet().getSilentPaymentScanAddress().getAddress();
spAddress.setText(silentPaymentAddress);
Image qrImage = getSilentPaymentsQrCode(silentPaymentAddress);
if(qrImage != null) {
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java
index 1bdd5bf..b5de85b 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java
@@ -594,6 +594,7 @@ public class SettingsController extends WalletFormController implements Initiali
private void replaceWallet(Wallet editedWallet) {
editedWallet.setName(getWalletForm().getWallet().getName());
editedWallet.setBirthDate(getWalletForm().getWallet().getBirthDate());
+ editedWallet.setBirthHeight(getWalletForm().getWallet().getBirthHeight());
editedWallet.setGapLimit(getWalletForm().getWallet().getGapLimit());
editedWallet.setWatchLast(getWalletForm().getWallet().getWatchLast());
editedWallet.setMasterWallet(getWalletForm().getWallet().getMasterWallet());
@@ -878,21 +879,24 @@ public class SettingsController extends WalletFormController implements Initiali
@Subscribe
public void walletAddressesChanged(WalletAddressesChangedEvent event) {
if(event.getWalletId().equals(walletForm.getWalletId())) {
- updateBirthDate(event.getWallet());
+ updateBirth(event.getWallet());
}
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(event.getWalletId().equals(walletForm.getWalletId())) {
- updateBirthDate(event.getWallet());
+ updateBirth(event.getWallet());
}
}
- private void updateBirthDate(Wallet wallet) {
+ private void updateBirth(Wallet wallet) {
if(!Objects.equals(wallet.getBirthDate(), walletForm.getWallet().getBirthDate())) {
walletForm.getWallet().setBirthDate(wallet.getBirthDate());
}
+ if(!Objects.equals(wallet.getBirthHeight(), walletForm.getWallet().getBirthHeight())) {
+ walletForm.getWallet().setBirthHeight(wallet.getBirthHeight());
+ }
}
@Subscribe
diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
index 2fb7ca7..f0a08ce 100644
--- a/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
+++ b/src/main/java/com/sparrowwallet/sparrow/wallet/WalletForm.java
@@ -2,6 +2,8 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
+import com.sparrowwallet.drongo.policy.PolicyType;
+import com.sparrowwallet.drongo.silentpayments.SilentPaymentScanAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -52,6 +54,10 @@ public class WalletForm {
private ElectrumServer.TransactionMempoolService transactionMempoolService;
+ private boolean spScanInProgress;
+ private boolean spSubscriptionHeld;
+ private boolean spPendingRefresh;
+
private final BooleanProperty lockedProperty = new SimpleBooleanProperty(false);
public WalletForm(Storage storage, Wallet currentWallet) {
@@ -155,82 +161,155 @@ public class WalletForm {
log.debug(nodes == null ? wallet.getFullName() + " refreshing full wallet history" : wallet.getFullName() + " requesting node wallet history for " + nodeRangesToString(nodes));
}
- Set<WalletNode> walletTransactionNodes = getWalletTransactionNodes(nodes);
- if(!wallet.isNested() && (walletTransactionNodes == null || !walletTransactionNodes.isEmpty())) {
- ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, filterToWallets, walletTransactionNodes);
- historyService.setOnSucceeded(workerStateEvent -> {
- if(historyService.getValue()) {
- EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
- updateWallets(blockHeight, previousWallet);
- }
- });
- historyService.setOnFailed(workerStateEvent -> {
- if(workerStateEvent.getSource().getException() instanceof AllHistoryChangedException) {
- if(getWallet().isMasterWallet() && getWallet().getKeystores().stream().anyMatch(Keystore::needsPassphrase)) {
- Optional<ButtonType> optType = AppServices.showWarningDialog("Reopen " + getWallet().getMasterName() + "?",
- "It appears that the history of this wallet has changed, which may be caused by an incorrect passphrase. " +
- "Note that any typos when entering the passphrase will create an entirely different wallet, with a correspondingly different history.\n\n" +
- "You can proceed with a full refresh of this wallet, or you can reopen it to enter the passphrase again.",
- new ButtonType("Refresh Wallet", ButtonBar.ButtonData.CANCEL_CLOSE),
- new ButtonType("Reopen Wallet", ButtonBar.ButtonData.OK_DONE));
-
- if(optType.isPresent() && optType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
- EventManager.get().post(new RequestWalletOpenEvent(AppServices.get().getWindowForWallet(getWalletId()), getStorage().getWalletFile()));
- return;
- }
- }
+ if(wallet.getPolicyType() == PolicyType.SINGLE_SP && nodes == null) {
+ refreshHistorySP(previousWallet, blockHeight);
+ } else {
+ refreshHistoryHD(previousWallet, blockHeight, filterToWallets, nodes);
+ }
+ }
+ }
+
+ private void refreshHistoryHD(Wallet previousWallet, Integer blockHeight, List<Wallet> filterToWallets, Set<WalletNode> nodes) {
+ Set<WalletNode> walletTransactionNodes = getWalletTransactionNodes(nodes);
+ if(!wallet.isNested() && (walletTransactionNodes == null || !walletTransactionNodes.isEmpty())) {
+ ElectrumServer.TransactionHistoryService historyService = new ElectrumServer.TransactionHistoryService(wallet, filterToWallets, walletTransactionNodes);
+ historyService.setOnSucceeded(workerStateEvent -> {
+ if(historyService.getValue()) {
+ EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
+ updateWallets(blockHeight, previousWallet);
+ }
+ });
+ historyService.setOnFailed(workerStateEvent -> {
+ handleHistoryFailed(previousWallet, workerStateEvent.getSource().getException());
+ });
+
+ EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes));
+ historyService.start();
+ }
+ if(wallet.isMasterWallet() && wallet.hasPaymentCode() && refreshNotificationNode(nodes)) {
+ ElectrumServer.PaymentCodesService paymentCodesService = new ElectrumServer.PaymentCodesService(getWalletId(), wallet);
+ paymentCodesService.setOnSucceeded(successEvent -> {
+ List<Wallet> addedWallets = paymentCodesService.getValue();
+ for(Wallet addedWallet : addedWallets) {
+ if(!storage.isPersisted(addedWallet)) {
try {
- storage.backupWallet();
- } catch(IOException e) {
- log.error("Error backing up wallet", e);
+ storage.saveWallet(addedWallet);
+ EventManager.get().post(new NewChildWalletSavedEvent(storage, wallet, addedWallet));
+ } catch(Exception e) {
+ log.error("Error saving wallet", e);
+ AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
}
+ }
+ }
+ if(!addedWallets.isEmpty()) {
+ EventManager.get().post(new ChildWalletsAddedEvent(storage, wallet, addedWallets));
+ }
+ });
+ paymentCodesService.setOnFailed(failedEvent -> {
+ log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException());
+ });
+ paymentCodesService.start();
+ }
+ }
- wallet.clearHistory();
- AppServices.clearTransactionHistoryCache(wallet);
- EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId()));
- } else {
- if(AppServices.isConnected()) {
- log.error("Error retrieving wallet history", workerStateEvent.getSource().getException());
- } else {
- log.debug("Disconnected while retrieving wallet history", workerStateEvent.getSource().getException());
- }
+ private void refreshHistorySP(Wallet previousWallet, Integer blockHeight) {
+ SilentPaymentScanAddress scanAddress = wallet.getSilentPaymentScanAddress();
+ //Self-heal: connection-change wipes spScanCaches without resetting per-form flags; reconcile here.
+ if(spSubscriptionHeld && !ElectrumServer.hasSilentPaymentsCache(scanAddress)) {
+ spSubscriptionHeld = false;
+ }
- EventManager.get().post(new WalletHistoryFailedEvent(wallet, workerStateEvent.getSource().getException()));
- }
- });
+ if(spScanInProgress) {
+ //Single-flight: defer until the in-flight scan settles; firePendingRefreshIfRequested re-triggers.
+ spPendingRefresh = true;
+ return;
+ }
- EventManager.get().post(new WalletHistoryStartedEvent(wallet, nodes));
- historyService.start();
+ boolean shouldHold = !spSubscriptionHeld;
+
+ ElectrumServer.SilentPaymentScanService scanService = new ElectrumServer.SilentPaymentScanService(wallet, shouldHold, computeNeededStart(wallet));
+ scanService.setOnSucceeded(workerStateEvent -> {
+ spScanInProgress = false;
+ spSubscriptionHeld = true;
+ if(scanService.getValue()) {
+ EventManager.get().post(new WalletHistoryFinishedEvent(wallet));
+ updateWallets(blockHeight, previousWallet);
+ }
+ firePendingRefreshIfRequested();
+ });
+ scanService.setOnFailed(workerStateEvent -> {
+ spScanInProgress = false;
+ if(scanService.isReleasedHold()) {
+ spSubscriptionHeld = false;
}
+ handleHistoryFailed(previousWallet, workerStateEvent.getSource().getException());
+ firePendingRefreshIfRequested();
+ });
- if(wallet.isMasterWallet() && wallet.hasPaymentCode() && refreshNotificationNode(nodes)) {
- ElectrumServer.PaymentCodesService paymentCodesService = new ElectrumServer.PaymentCodesService(getWalletId(), wallet);
- paymentCodesService.setOnSucceeded(successEvent -> {
- List<Wallet> addedWallets = paymentCodesService.getValue();
- for(Wallet addedWallet : addedWallets) {
- if(!storage.isPersisted(addedWallet)) {
- try {
- storage.saveWallet(addedWallet);
- EventManager.get().post(new NewChildWalletSavedEvent(storage, wallet, addedWallet));
- } catch(Exception e) {
- log.error("Error saving wallet", e);
- AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
- }
- }
- }
- if(!addedWallets.isEmpty()) {
- EventManager.get().post(new ChildWalletsAddedEvent(storage, wallet, addedWallets));
- }
- });
- paymentCodesService.setOnFailed(failedEvent -> {
- log.error("Could not determine payment codes for wallet " + wallet.getFullName(), failedEvent.getSource().getException());
- });
- paymentCodesService.start();
+ EventManager.get().post(new WalletHistoryStartedEvent(wallet, null));
+ spScanInProgress = true;
+ scanService.start();
+ }
+
+ private void firePendingRefreshIfRequested() {
+ if(spPendingRefresh) {
+ spPendingRefresh = false;
+ Platform.runLater(() -> refreshHistory(AppServices.getCurrentBlockHeight()));
+ }
+ }
+
+ private void handleHistoryFailed(Wallet previousWallet, Throwable exception) {
+ if(exception instanceof AllHistoryChangedException) {
+ if(getWallet().isMasterWallet() && getWallet().getKeystores().stream().anyMatch(Keystore::needsPassphrase)) {
+ Optional<ButtonType> optType = AppServices.showWarningDialog("Reopen " + getWallet().getMasterName() + "?",
+ "It appears that the history of this wallet has changed, which may be caused by an incorrect passphrase. " +
+ "Note that any typos when entering the passphrase will create an entirely different wallet, with a correspondingly different history.\n\n" +
+ "You can proceed with a full refresh of this wallet, or you can reopen it to enter the passphrase again.",
+ new ButtonType("Refresh Wallet", ButtonBar.ButtonData.CANCEL_CLOSE),
+ new ButtonType("Reopen Wallet", ButtonBar.ButtonData.OK_DONE));
+
+ if(optType.isPresent() && optType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
+ EventManager.get().post(new RequestWalletOpenEvent(AppServices.get().getWindowForWallet(getWalletId()), getStorage().getWalletFile()));
+ return;
+ }
}
+
+ try {
+ storage.backupWallet();
+ } catch(IOException e) {
+ log.error("Error backing up wallet", e);
+ }
+
+ wallet.clearHistory();
+ AppServices.clearTransactionHistoryCache(wallet);
+ spSubscriptionHeld = false;
+ EventManager.get().post(new WalletHistoryClearedEvent(wallet, previousWallet, getWalletId()));
+ } else {
+ if(AppServices.isConnected()) {
+ log.error("Error retrieving wallet history", exception);
+ } else {
+ log.debug("Disconnected while retrieving wallet history", exception);
+ }
+
+ EventManager.get().post(new WalletHistoryFailedEvent(wallet, exception));
}
}
+ private static int computeNeededStart(Wallet wallet) {
+ Integer stored = wallet.getStoredBlockHeight();
+ if(stored != null && stored > 0) {
+ return Math.max(0, stored - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM);
+ }
+ if(wallet.getBirthHeight() != null) {
+ return Math.max(0, wallet.getBirthHeight() - BlockTransactionHash.BLOCKS_TO_FULLY_CONFIRM);
+ }
+ if(wallet.getBirthDate() != null) {
+ return (int)(wallet.getBirthDate().getTime() / 1000L);
+ }
+ return 0;
+ }
+
private void updateWallets(Integer blockHeight, Wallet previousWallet) {
List<WalletNode> nestedHistoryChangedNodes = new ArrayList<>();
for(Wallet childWallet : new ArrayList<>(wallet.getChildWallets())) {
@@ -246,6 +325,11 @@ public class WalletForm {
}
private List<WalletNode> updateWallet(Integer blockHeight, Wallet currentWallet, Wallet previousWallet, List<WalletNode> nestedHistoryChangedNodes) {
+ OptionalInt min = currentWallet.getTransactions().values().stream().filter(blockTx -> blockTx.getHeight() > 0).mapToInt(BlockTransaction::getHeight).min();
+ if(min.isPresent() && (currentWallet.getBirthHeight() == null || min.getAsInt() < currentWallet.getBirthHeight())) {
+ currentWallet.setBirthHeight(min.getAsInt());
+ }
+
if(blockHeight != null) {
currentWallet.setStoredBlockHeight(blockHeight);
}
@@ -401,6 +485,30 @@ public class WalletForm {
return accountEntries;
}
+ @Subscribe
+ public void silentPaymentsScanProgress(SilentPaymentsScanProgressEvent event) {
+ if(wallet.getPolicyType() != PolicyType.SINGLE_SP || !wallet.isValid() || !event.getSpAddress().equals(wallet.getSilentPaymentScanAddress().getAddress())) {
+ return;
+ }
+
+ if(spScanInProgress && event.getProgress() < 1.0) {
+ EventManager.get().post(new WalletHistoryStatusEvent(wallet, true, "Scanning silent payments... (" + Math.round(event.getProgress() * 100) + "%)"));
+ }
+ }
+
+ @Subscribe
+ public void silentPaymentsHistoryUpdated(SilentPaymentsHistoryUpdatedEvent event) {
+ if(wallet.getPolicyType() != PolicyType.SINGLE_SP || !wallet.isValid() || !event.getSpAddress().equals(wallet.getSilentPaymentScanAddress().getAddress())) {
+ return;
+ }
+
+ if(spScanInProgress) {
+ spPendingRefresh = true;
+ } else {
+ refreshHistory(AppServices.getCurrentBlockHeight());
+ }
+ }
+
@Subscribe
public void walletDataChanged(WalletDataChangedEvent event) {
if(event.getWallet().equals(wallet)) {
@@ -421,6 +529,7 @@ public class WalletForm {
//Clear the cache - we will need to fetch everything again
AppServices.clearTransactionHistoryCache(wallet);
+ spSubscriptionHeld = false;
refreshHistory(AppServices.getCurrentBlockHeight());
}
}
@@ -658,15 +767,16 @@ public class WalletForm {
public void walletTabsClosed(WalletTabsClosedEvent event) {
for(WalletTabData tabData : event.getClosedWalletTabData()) {
if(tabData.getWalletForm() == this) {
- if(wallet.isMasterWallet()) {
- storage.close();
+ EventManager.get().unregister(this);
+ for(WalletForm nestedWalletForm : nestedWalletForms) {
+ EventManager.get().unregister(nestedWalletForm);
}
if(wallet.isValid()) {
AppServices.clearTransactionHistoryCache(wallet);
+ spSubscriptionHeld = false;
}
- EventManager.get().unregister(this);
- for(WalletForm nestedWalletForm : nestedWalletForms) {
- EventManager.get().unregister(nestedWalletForm);
+ if(wallet.isMasterWallet()) {
+ storage.close();
}
}
}
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.